Calendar: header follows the scrolled-to year in continuous year view
Some checks failed
CI / build-and-test (push) Has been cancelled

The top title now tracks whichever year is at the top of the year scroll
(e.g. scroll into 2027 and the header reads '2027'), instead of staying on
the displayed month. In month/week/day modes it still shows month + year.

- visibleYear state driven by a YearTopKey preference reporting each year
  block's top offset in the 'yearScroll' coordinate space
- headerTitle switches on viewMode
- iOS 16-safe (GeometryReader + PreferenceKey, no scrollPosition API)

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-07 00:09:22 +03:00
parent a37807aff0
commit 6456d8f69a

View File

@@ -294,6 +294,8 @@ struct CalendarView: View {
}()
@State private var viewMode: CalendarViewMode = .month
@State private var calTaskListMode: Bool = false
/// Year currently at the top of the continuous year scroll (drives the header in .year mode).
@State private var visibleYear: Int = Calendar.current.component(.year, from: Date())
@State private var showAddTask = false
@State private var showCalendarConnect = false
@State private var showWorkoutSession = false
@@ -333,7 +335,7 @@ struct CalendarView: View {
// Center: month + year
Spacer()
Text(monthTitle)
Text(headerTitle)
.font(AppFonts.sans(17, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Spacer()
@@ -434,6 +436,12 @@ struct CalendarView: View {
}
}
/// In the continuous year view the header follows the scrolled-to year;
/// otherwise it's the month + year of the displayed month.
private var headerTitle: String {
viewMode == .year ? String(visibleYear) : monthTitle
}
private var monthTitle: String {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
@@ -673,6 +681,15 @@ struct CalendarView: View {
// MARK: - Year
// Reports each year block's top offset within the year scroll, so the
// header can follow whichever year is currently at the top.
private struct YearTopKey: PreferenceKey {
static var defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue()) { _, new in new }
}
}
// Continuous, TickTick-style: years flow one after another in an infinite
// scroll. Opens anchored on the current year; scroll up for past years,
// down for future ones no paging, no dead space below December.
@@ -693,7 +710,22 @@ struct CalendarView: View {
.padding(.top, 8)
.padding(.bottom, 96) // clear the floating tab bar / + button
}
.onAppear { proxy.scrollTo(currentYear, anchor: .top) }
.coordinateSpace(name: "yearScroll")
.onPreferenceChange(YearTopKey.self) { tops in
// The header year is the topmost block that has reached the top edge:
// among blocks at/above a small threshold, the one nearest the top.
let threshold: CGFloat = 60
let reached = tops.filter { $0.value <= threshold }
if let y = reached.max(by: { $0.value < $1.value })?.key
?? tops.min(by: { $0.value < $1.value })?.key,
y != visibleYear {
visibleYear = y
}
}
.onAppear {
visibleYear = currentYear
proxy.scrollTo(currentYear, anchor: .top)
}
}
}
@@ -712,6 +744,14 @@ struct CalendarView: View {
}
.padding(.horizontal, 16)
}
.background(
GeometryReader { geo in
Color.clear.preference(
key: YearTopKey.self,
value: [year: geo.frame(in: .named("yearScroll")).minY]
)
}
)
}
private func cvMiniMonth(_ month: Date) -> some View {