diff --git a/Keychy/Keychy/Core/Extensions/View+PullToRefresh.swift b/Keychy/Keychy/Core/Extensions/View+PullToRefresh.swift index 6932cfa57..53a7e04b5 100644 --- a/Keychy/Keychy/Core/Extensions/View+PullToRefresh.swift +++ b/Keychy/Keychy/Core/Extensions/View+PullToRefresh.swift @@ -73,10 +73,10 @@ struct PullToRefreshModifier: ViewModifier { } /// Refresh 중 content를 밀어내는 Spacer - /// - Refresh 중: topPadding이 있으면 topPadding, 없으면 60px (일정한 간격 유지) + /// - Refresh 중: 항상 60px (일정한 간격 유지) private var contentSpacer: some View { Spacer() - .frame(height: shouldHoldIndicator ? max(topPadding, 60) : min(pullDistance * 0.3, 60)) + .frame(height: shouldHoldIndicator ? 50 : min(pullDistance * 0.3, 50)) } /// Pull to Refresh Indicator @@ -90,7 +90,7 @@ struct PullToRefreshModifier: ViewModifier { isRefreshing: isRefreshing ) .allowsHitTesting(false) - .padding(.top, shouldHoldIndicator ? (topPadding == 0 ? 20 : topPadding + 40) : topPadding - 40) + .padding(.top, shouldHoldIndicator ? (topPadding == 0 ? 20 : topPadding + 20) : topPadding - 40) } /// 드래그 제스처 @@ -163,7 +163,7 @@ struct PullToRefreshModifier: ViewModifier { } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.spring(response: 0.4, dampingFraction: 1.0)) { + withAnimation(.easeOut(duration: 0.25)) { shouldHoldIndicator = false indicatorOpacity = 0 } diff --git a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift index 124c7abff..5d4d9b7af 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Main/CollectionView+NormalMode.swift @@ -9,6 +9,13 @@ import SwiftUI // MARK: - Normal Mode View extension CollectionView { + /// 오버레이 헤더 높이 (headerSection + tagSection + collectionHeader) + /// - headerSection: 60(top padding) + ~40(buttons) + 2(padding) ≈ 102pt + /// - tagSection: 4(Spacing.xs) + 35(TabBar) ≈ 39pt + /// - collectionHeader: ~35pt (sortButton + spacing) + /// - 총합: ~185pt + private var overlayHeaderHeight: CGFloat { 185 } + // MARK: - Normal Mode View var normalModeView: some View { Group { @@ -44,16 +51,26 @@ extension CollectionView { } } } else { - // 정상 상태: 기존 VStack 형태 - VStack { - headerSection - .padding(.horizontal, Spacing.margin) - .padding(.top, 2) + // 정상 상태: ZStack 오버레이 형태 (iOS 빌트인 탭 스크롤 지원) + ZStack(alignment: .top) { + // 전체 화면 ScrollView (pullToRefresh가 생성) + normalCollectionSection - tagSection - .padding(.horizontal, Spacing.xs) + // 고정 오버레이 헤더 + VStack(spacing: 0) { + headerSection + .padding(.horizontal, Spacing.margin) + .padding(.top, 2) - normalCollectionSection + tagSection + .padding(.horizontal, Spacing.xs) + + collectionHeader + .padding(.horizontal, Spacing.padding) + .padding(.top, 10) + .padding(.bottom, 12) + } + .background(Color.white) } .contentShape(Rectangle()) .onTapGesture { @@ -147,35 +164,37 @@ extension CollectionView { } private var normalCollectionSection: some View { - VStack(spacing: 10) { - collectionHeader - .padding(.horizontal, Spacing.padding) + VStack(spacing: 0) { + // 오버레이 헤더 높이만큼 상단 여백 + Spacer() + .frame(height: overlayHeaderHeight) if filteredKeyrings.isEmpty { emptyView } else { collectionGridView(keyrings: filteredKeyrings) .padding(.horizontal, Spacing.xs) - .pullToRefresh(topPadding: 0) { - try? await Task.sleep(for: .seconds(1)) - fetchUserData() - retryFailedCaches() - } - .simultaneousGesture( - DragGesture().onChanged { _ in - if showSearchBar { - isSearchFieldFocused = false - - if !isSearching { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSearchBar = false - } - } - } - } - ) + .padding(.top, 20) } } + .pullToRefresh(topPadding: overlayHeaderHeight) { + try? await Task.sleep(for: .seconds(1)) + fetchUserData() + retryFailedCaches() + } + .simultaneousGesture( + DragGesture().onChanged { _ in + if showSearchBar { + isSearchFieldFocused = false + + if !isSearching { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSearchBar = false + } + } + } + } + ) } var collectionHeader: some View { diff --git a/functions/package-lock.json b/functions/package-lock.json index bb1275ec8..72632e41d 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -3300,9 +3300,9 @@ "peer": true }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", "funding": [ { "type": "github", @@ -3312,7 +3312,7 @@ "license": "MIT", "optional": true, "dependencies": { - "strnum": "^1.1.1" + "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6561,9 +6561,9 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "funding": [ { "type": "github", diff --git a/functions/package.json b/functions/package.json index 7ae822f7b..84abd1442 100644 --- a/functions/package.json +++ b/functions/package.json @@ -1,7 +1,8 @@ { "name": "functions", "overrides": { - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "fast-xml-parser": "^5.3.4" }, "scripts": { "build": "tsc",