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
This commit is contained in:
songyc macbook 2026-07-10 04:11:14 +09:00
parent 352be3e29b
commit ecc4c016f7
6 changed files with 85 additions and 59 deletions

View File

@ -176,6 +176,23 @@ enum DebugSeed {
readingQuest.targetSeconds = 45 * 60 readingQuest.targetSeconds = 45 * 60
readingQuest.direction = .atLeast readingQuest.direction = .atLeast
context.insert(readingQuest) 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 #endif

View File

@ -30,13 +30,7 @@ enum SettingsKeys {
static let goalCardStyle = "settings.goalCardStyle" static let goalCardStyle = "settings.goalCardStyle"
} }
/// /// ( )
enum MainGoalCard {
///
static let maxPinned = 3
}
///
enum GoalCardStyle: String, CaseIterable, Identifiable { enum GoalCardStyle: String, CaseIterable, Identifiable {
/// ( 3) // /// ( 3) //
case perQuest case perQuest

View File

@ -270,7 +270,7 @@
"모음" : { "모음" : {
}, },
"모음 탭" : { "모음 탭 목표 진행 현황 (최대 %lld개)" : {
}, },
"목표" : { "목표" : {
@ -291,10 +291,10 @@
"목표 진행 현황" : { "목표 진행 현황" : {
}, },
"목표 진행 현황 표시" : { "목표 추가" : {
}, },
"목표 추가" : { "목표 탭에서 목표를 만들면 여기서 선택할 수 있어요." : {
}, },
"목표 확인" : { "목표 확인" : {
@ -376,7 +376,7 @@
"선택" : { "선택" : {
}, },
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요." : { "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요." : {
}, },
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : { "선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : {
@ -600,9 +600,6 @@
}, },
"표시 방식" : { "표시 방식" : {
},
"표시 안 함" : {
}, },
"표시할 기록이 없어요" : { "표시할 기록이 없어요" : {

View File

@ -35,6 +35,8 @@ struct MainView: View {
@State private var deletingAction: Action? @State private var deletingAction: Action?
@State private var memoSession: TimeSession? @State private var memoSession: TimeSession?
@State private var memoEntry: CountEntry? @State private var memoEntry: CountEntry?
@State private var pagedGoalID: PersistentIdentifier?
@State private var goalCardHeights: [PersistentIdentifier: CGFloat] = [:]
private var anyRunning: Bool { !runningSessions.isEmpty } private var anyRunning: Bool { !runningSessions.isEmpty }
@ -118,30 +120,65 @@ struct MainView: View {
#endif #endif
} }
// MARK: (1= , 2 = ) // MARK: (1= , 2 = )
@ViewBuilder @ViewBuilder
private var goalArea: some View { private var goalArea: some View {
if pinnedGoals.count == 1, let goal = pinnedGoals.first { if pinnedGoals.count == 1, let goal = pinnedGoals.first {
GoalSummaryCard(goal: goal) GoalSummaryCard(goal: goal)
} else { } else {
ScrollView(.horizontal, showsIndicators: false) { VStack(spacing: 8) {
HStack(spacing: 10) { ScrollView(.horizontal, showsIndicators: false) {
ForEach(pinnedGoals.prefix(MainGoalCard.maxPinned)) { goal in HStack(alignment: .top, spacing: 10) {
GoalSummaryCard(goal: goal, compact: true) ForEach(pinnedGoals) { goal in
// , ( ) GoalSummaryCard(goal: goal)
.containerRelativeFrame(.horizontal) { length, _ in // 1
min(length * 0.72, 300) .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: // MARK:
private var runningArea: some View { private var runningArea: some View {
@ -332,8 +369,6 @@ struct MainView: View {
struct GoalSummaryCard: View { struct GoalSummaryCard: View {
let goal: Goal let goal: Goal
/// ( )
var compact: Bool = false
@AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue
@ -376,22 +411,19 @@ struct GoalSummaryCard: View {
} }
} }
if !goal.sortedQuests.isEmpty { if !goal.sortedQuests.isEmpty {
if compact { //
combinedBlock switch cardStyle {
} else { case .perQuest:
switch cardStyle { ForEach(goal.sortedQuests.prefix(3)) { quest in
case .perQuest: questSpanBlock(quest)
ForEach(goal.sortedQuests.prefix(3)) { quest in
questSpanBlock(quest)
}
case .combined:
combinedBlock
} }
case .combined:
combinedBlock
} }
} }
} }
.padding(14) .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)) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
} }

View File

@ -36,10 +36,6 @@ struct SettingsView: View {
} }
} }
private var pinnedGoals: [Goal] {
goals.filter(\.showsOnMain)
}
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
Form { Form {
@ -72,8 +68,6 @@ struct SettingsView: View {
ForEach(goals) { goal in ForEach(goals) { goal in
pinnedGoalRow(goal) pinnedGoalRow(goal)
} }
}
if pinnedGoals.count == 1 {
Picker("표시 방식", selection: $goalCardStyle) { Picker("표시 방식", selection: $goalCardStyle) {
ForEach(GoalCardStyle.allCases) { style in ForEach(GoalCardStyle.allCases) { style in
Text(style.label).tag(style.rawValue) Text(style.label).tag(style.rawValue)
@ -81,9 +75,9 @@ struct SettingsView: View {
} }
} }
} header: { } header: {
Text("모음 탭 목표 진행 현황 (최대 \(MainGoalCard.maxPinned)개)") Text("모음 탭 목표 진행 현황")
} footer: { } footer: {
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요.") Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요.")
} }
.id("goalSection") .id("goalSection")
Section { Section {
@ -158,17 +152,11 @@ struct SettingsView: View {
} }
} }
// MARK: ( MainGoalCard.maxPinned ) // MARK: ( )
private func pinnedGoalRow(_ goal: Goal) -> some View { private func pinnedGoalRow(_ goal: Goal) -> some View {
let isPinned = goal.showsOnMain Button {
let atMax = pinnedGoals.count >= MainGoalCard.maxPinned goal.showsOnMain.toggle()
return Button {
if isPinned {
goal.showsOnMain = false
} else if !atMax {
goal.showsOnMain = true
}
} label: { } label: {
HStack { HStack {
Label { Label {
@ -180,7 +168,7 @@ struct SettingsView: View {
.foregroundStyle(goal.color) .foregroundStyle(goal.color)
} }
Spacer() Spacer()
if isPinned { if goal.showsOnMain {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(AppTheme.green) .foregroundStyle(AppTheme.green)
.fontWeight(.semibold) .fontWeight(.semibold)
@ -189,8 +177,6 @@ struct SettingsView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(!isPinned && atMax)
.opacity(!isPinned && atMax ? 0.5 : 1)
} }
// MARK: // MARK: