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