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