From ecc4c016f7631798e0cc09867eca03cdac4268d6 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 04:11:14 +0900 Subject: [PATCH] feat(main): implement snap-scrolling carousel for goal widgets with unified display options - Remove the maximum limit for selected goal widgets on the main tab - Unify the display format (aggregate vs. individual) to apply globally across all selected goals - Redesign the horizontal scrolling into a full-width snap carousel (pagination style) - Ensure only one widget is fully visible at a time with no adjacent cards partially showing - Apply snap scrolling so light swipes firmly transition to the next or previous widget --- .../UserInterfaceState.xcuserstate | Bin 29483 -> 30001 bytes myApp/HaruDanim/IOS/Core/DebugSeed.swift | 17 ++++ myApp/HaruDanim/IOS/Core/Settings.swift | 8 +- myApp/HaruDanim/IOS/Localizable.xcstrings | 11 +-- myApp/HaruDanim/IOS/Views/MainView.swift | 82 ++++++++++++------ myApp/HaruDanim/IOS/Views/SettingsView.swift | 26 ++---- 6 files changed, 85 insertions(+), 59 deletions(-) diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 153e7ada266dc86b8e4eadffc74a186e3d798728..ce9c92491f70017e33766bfb22bbc69b741c6e64 100644 GIT binary patch delta 927 zcmZuvX>3eU6u#$7mFcwOb!IwM-PCARwf22)OR42e&1*ZBc3$tDJ~dRQnfEHTq_;tW zh=?a*Us@uFSSAsPA0h~1Nt8%PB!mbOLik}x@upKM;{N&0J?Hz*cJHZIaPBRfRUJKS z?`_$`!>b&-tP!)|SlI45o*yw&SB5XUuf)UKa7$GlV2njST`i@Z5mGGic1K$JpuAxd zCQfoqE-alrXYM@7yP!(eExrQiqmTCu_1Y^%sU+B-J<7=Jm(}0u8jzhcFxT)&fi=nk zQCd<`PwUhwQJSu({!pM%3x*bn(k!Za1GU;xgD27zifdX9={$*>1m3e3kLAhwVP8t~ z1MA@Yf+0g?Avs#3JBPbb3j`ZfS?K$3q)^jB0oJ4`DtFpAY>45#9wa%f!$+sKjTkwK zn|U0!w2c`%j>q!^p2Y34V2`GVH2g;_3;jf?xwt$sS&2fmrjQ~jnigoNQ~%=h5v3@l zqO?M#b=~rIr*+D-BU8DRJ0iu4W~85Fxf*beGN7Psa6cF7p^WcC%%M-f)&z_(W-5lSWy+QWy%+ z)otaK9VK3M#zu9l|+jWbYf&Qdl$Nwna delta 488 zcmdn^igEQb#tp?Htb%$CmCrU;h)m-HF&p?d3o9?z0WphKY~JsGkcTmAb7PDaBNG$r zI#C(Fd?GICD#iqp5?2=fbe%g-yhA}6oFr>LZ?0^|p0R;5Px z1*f^?B&HVw`F@GHsmcy&3}7J0A<)3qz&`mxoMhZ3bq!6eD3#=##Ny&SprQx}GZmx> zWauJIZJ;^+Nm;4MB|x>hdNF>%E}6+CnfZB%MU{(mwSj^LhVcSIe!-3f;zv7_u2k7^)dM7`hmG82T7yFwAFI&ajDL3&S>s9Spk|_Ane~ zIKyz6;SR%NhBplF89p+6X5?WMWRzi4Wz=ERXS86nW%Oe7Wej3WXUu~5fr$}9GZ;f@ zD8*Rc$TZ;+(56m~`y3Bq{epcrpGXvARONNQJ~zLSDK=j~fFXn-ALyh+hDrtnAXx$= zjTx*M3>eHdGbf)Bbc){;zcqee{F(U6@weii$A5|c9{(%;ZvsOCa{^m}aDrHZM1pjJ pT!KP^Qi4i?XF^`WjD$l8rxUIu+)a3p$ehTN$U9lERE{Z;9{^7dlPdrK diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index e4174df..b3b5dec 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -176,6 +176,23 @@ enum DebugSeed { readingQuest.targetSeconds = 45 * 60 readingQuest.direction = .atLeast context.insert(readingQuest) + + // 네 번째 목표 (모음 탭 목표 카드 개수 제한 해제 검증용) + let run = Goal( + title: "꾸준한 달리기", + symbolName: "figure.run", + colorHex: "#4A7A9D", + startDate: cal.date(byAdding: .day, value: -14, to: now)!, + endDate: cal.date(byAdding: .day, value: 45, to: now)! + ) + context.insert(run) + let weeklyRunQuest = Quest(goal: run) + weeklyRunQuest.targetAction = running + weeklyRunQuest.measure = .time + weeklyRunQuest.period = .weekly + weeklyRunQuest.targetSeconds = 2 * 3600 + weeklyRunQuest.direction = .atLeast + context.insert(weeklyRunQuest) } } #endif diff --git a/myApp/HaruDanim/IOS/Core/Settings.swift b/myApp/HaruDanim/IOS/Core/Settings.swift index 6653d44..1b98977 100644 --- a/myApp/HaruDanim/IOS/Core/Settings.swift +++ b/myApp/HaruDanim/IOS/Core/Settings.swift @@ -30,13 +30,7 @@ enum SettingsKeys { static let goalCardStyle = "settings.goalCardStyle" } -/// 모음 탭 상단 목표 카드 구성 -enum MainGoalCard { - /// 동시에 표시할 수 있는 목표 최대 개수 - static let maxPinned = 3 -} - -/// 모음 탭 목표 진행 현황 카드의 표시 방식 +/// 모음 탭 목표 진행 현황 카드의 표시 방식 (선택된 모든 목표 카드에 일괄 적용) enum GoalCardStyle: String, CaseIterable, Identifiable { /// 다짐(최대 3개)별로 하루/주간/월간 진행률을 각각 표시 case perQuest diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 925f239..58a34f6 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -270,7 +270,7 @@ "모음" : { }, - "모음 탭" : { + "모음 탭 목표 진행 현황 (최대 %lld개)" : { }, "목표" : { @@ -291,10 +291,10 @@ "목표 진행 현황" : { }, - "목표 진행 현황 표시" : { + "목표 추가" : { }, - "목표 추가" : { + "목표 탭에서 목표를 만들면 여기서 선택할 수 있어요." : { }, "목표 확인" : { @@ -376,7 +376,7 @@ "선택" : { }, - "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요." : { + "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요." : { }, "선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : { @@ -600,9 +600,6 @@ }, "표시 방식" : { - }, - "표시 안 함" : { - }, "표시할 기록이 없어요" : { diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index ee2583c..8ff1be1 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -35,6 +35,8 @@ struct MainView: View { @State private var deletingAction: Action? @State private var memoSession: TimeSession? @State private var memoEntry: CountEntry? + @State private var pagedGoalID: PersistentIdentifier? + @State private var goalCardHeights: [PersistentIdentifier: CGFloat] = [:] private var anyRunning: Bool { !runningSessions.isEmpty } @@ -118,30 +120,65 @@ struct MainView: View { #endif } - // MARK: 목표 진행 현황 영역 (1개=상세 카드, 2개 이상=가로 슬라이드 요약 카드) + // MARK: 목표 진행 현황 영역 (1개=단독 카드, 2개 이상=한 장씩 스냅 페이징 가로 스크롤) @ViewBuilder private var goalArea: some View { if pinnedGoals.count == 1, let goal = pinnedGoals.first { GoalSummaryCard(goal: goal) } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(pinnedGoals.prefix(MainGoalCard.maxPinned)) { goal in - GoalSummaryCard(goal: goal, compact: true) - // 좁은 화면에선 옆 카드가 살짝 보이는 슬라이드, 넓은 화면(아이패드 등)에선 한 줄에 나란히 들어옴 - .containerRelativeFrame(.horizontal) { length, _ in - min(length * 0.72, 300) - } + VStack(spacing: 8) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 10) { + ForEach(pinnedGoals) { goal in + GoalSummaryCard(goal: goal) + // 카드 하나가 화면 폭을 꽉 채워 한 번에 딱 1장만 보임 + .containerRelativeFrame(.horizontal) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + goalCardHeights[goal.persistentModelID] = height + } + } } + .scrollTargetLayout() } - .scrollTargetLayout() + // 살짝만 쓸어도 어중간한 위치 없이 카드 단위로 딱 맞춰 넘어감 (스냅 페이징) + .scrollTargetBehavior(.viewAligned(limitBehavior: .always)) + .scrollPosition(id: $pagedGoalID) + // 카드마다 다짐 수가 달라 높이가 다르므로, 영역 높이는 지금 보이는 카드에 맞춤 + .frame(height: currentGoalCardHeight, alignment: .top) + .clipped() + .animation(.snappy(duration: 0.25), value: currentGoalCardHeight) + goalPageIndicator } - .scrollTargetBehavior(.viewAligned) - .scrollClipDisabled() } } + private var currentPagedGoalID: PersistentIdentifier? { + pagedGoalID ?? pinnedGoals.first?.persistentModelID + } + + private var currentGoalCardHeight: CGFloat? { + currentPagedGoalID.flatMap { goalCardHeights[$0] } + } + + private var goalPageIndicator: some View { + HStack(spacing: 5) { + ForEach(pinnedGoals) { goal in + Circle() + .fill( + goal.persistentModelID == currentPagedGoalID + ? AnyShapeStyle(AppTheme.green) + : AnyShapeStyle(.tertiary) + ) + .frame(width: 5, height: 5) + } + } + .frame(maxWidth: .infinity) + .animation(.default, value: pagedGoalID) + } + // MARK: 현재 진행 중 영역 private var runningArea: some View { @@ -332,8 +369,6 @@ struct MainView: View { struct GoalSummaryCard: View { let goal: Goal - /// 여러 목표를 가로 슬라이드로 보여줄 때의 축약 모드 (전체 다짐 평균 한 줄만 표시) - var compact: Bool = false @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue @@ -376,22 +411,19 @@ struct GoalSummaryCard: View { } } if !goal.sortedQuests.isEmpty { - if compact { - combinedBlock - } else { - switch cardStyle { - case .perQuest: - ForEach(goal.sortedQuests.prefix(3)) { quest in - questSpanBlock(quest) - } - case .combined: - combinedBlock + // 설정의 표시 방식이 선택된 모든 목표 카드에 동일하게 적용됨 + switch cardStyle { + case .perQuest: + ForEach(goal.sortedQuests.prefix(3)) { quest in + questSpanBlock(quest) } + case .combined: + combinedBlock } } } .padding(14) - .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index bceb8b7..0720105 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -36,10 +36,6 @@ struct SettingsView: View { } } - private var pinnedGoals: [Goal] { - goals.filter(\.showsOnMain) - } - var body: some View { ScrollViewReader { proxy in Form { @@ -72,8 +68,6 @@ struct SettingsView: View { ForEach(goals) { goal in pinnedGoalRow(goal) } - } - if pinnedGoals.count == 1 { Picker("표시 방식", selection: $goalCardStyle) { ForEach(GoalCardStyle.allCases) { style in Text(style.label).tag(style.rawValue) @@ -81,9 +75,9 @@ struct SettingsView: View { } } } header: { - Text("모음 탭 목표 진행 현황 (최대 \(MainGoalCard.maxPinned)개)") + Text("모음 탭 목표 진행 현황") } footer: { - Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요.") + Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요.") } .id("goalSection") Section { @@ -158,17 +152,11 @@ struct SettingsView: View { } } - // MARK: 모음 탭 목표 선택 (최대 MainGoalCard.maxPinned개 다중 선택) + // MARK: 모음 탭 목표 선택 (개수 제한 없는 다중 선택) private func pinnedGoalRow(_ goal: Goal) -> some View { - let isPinned = goal.showsOnMain - let atMax = pinnedGoals.count >= MainGoalCard.maxPinned - return Button { - if isPinned { - goal.showsOnMain = false - } else if !atMax { - goal.showsOnMain = true - } + Button { + goal.showsOnMain.toggle() } label: { HStack { Label { @@ -180,7 +168,7 @@ struct SettingsView: View { .foregroundStyle(goal.color) } Spacer() - if isPinned { + if goal.showsOnMain { Image(systemName: "checkmark") .foregroundStyle(AppTheme.green) .fontWeight(.semibold) @@ -189,8 +177,6 @@ struct SettingsView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(!isPinned && atMax) - .opacity(!isPinned && atMax ? 0.5 : 1) } // MARK: 탭바 구성