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.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

View File

@ -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

View File

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

View File

@ -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))
}

View File

@ -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: