1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import SwiftUI
/// What a swipe between the profile's tabs writes on every frame.
///
/// Kept off `ProfileView`'s `@State` on purpose. Anything the screen's body
/// reads re-evaluates the whole screen when it changes, and a swipe changes
/// the offset a hundred times a second. Only `ProfileTabBar` and
/// `ProfilePagerHeight` read these, so those two are all a swipe re-evaluates.
@MainActor
@Observable
final class ProfilePagerState {
/// Horizontal content offset of the pager.
var offsetX: CGFloat = 0
/// Width of one page; zero until laid out.
var pageWidth: CGFloat = 0
/// Measured height of each page's content.
var pageHeights: [ProfileViewMode: CGFloat] = [:]
/// Whether the tab bar has scrolled up past the top of the viewport, which
/// decides whether a tab change scrolls back to it.
var scrolledPastTop = false
static let modes: [ProfileViewMode] = [.grid, .favorites, .stories]
/// Position across the pages as a fraction: 0 is the first page, 1 the
/// second. Falls back to the selected mode until the pager has a width.
func progress(selected: ProfileViewMode) -> CGFloat {
guard pageWidth > 0 else {
return CGFloat(Self.modes.firstIndex(of: selected) ?? 0)
}
return max(0, min(offsetX / pageWidth, CGFloat(Self.modes.count - 1)))
}
func activeIndex(selected: ProfileViewMode) -> Int {
Int(progress(selected: selected).rounded())
}
/// Height the pager should be at the current offset: the two pages the
/// swipe is between, blended by how far it is between them.
func interpolatedHeight(selected: ProfileViewMode) -> CGFloat {
let fallback: CGFloat = 200
let heights = Self.modes.map { pageHeights[$0] ?? fallback }
let progress = progress(selected: selected)
let lower = Int(progress.rounded(.down))
let upper = min(lower + 1, Self.modes.count - 1)
let fraction = progress - CGFloat(lower)
return max(heights[lower] * (1 - fraction) + heights[upper] * fraction, fallback)
}
}
/// The three tab buttons and the indicator that tracks the swipe beneath them.
struct ProfileTabBar: View {
let pager: ProfilePagerState
let selected: ProfileViewMode
let onSelect: (ProfileViewMode) -> Void
var body: some View {
let modes = ProfilePagerState.modes
let activeIndex = pager.activeIndex(selected: selected)
HStack(spacing: 0) {
ForEach(Array(modes.enumerated()), id: \.element) { index, mode in
button(mode, isActive: index == activeIndex)
}
}
.overlay(alignment: .bottomLeading) {
GeometryReader { tabBarGeo in
let tabWidth = tabBarGeo.size.width / CGFloat(modes.count)
let indicatorWidth: CGFloat = 32
let xOffset = pager.progress(selected: selected) * tabWidth + (tabWidth - indicatorWidth) / 2
Rectangle()
.fill(Color.accentColor)
.frame(width: indicatorWidth, height: 2.5)
.offset(x: xOffset, y: -6)
}
.frame(height: 2.5)
.allowsHitTesting(false)
}
}
private func button(_ mode: ProfileViewMode, isActive: Bool) -> some View {
Button {
onSelect(mode)
} label: {
Image(systemName: isActive ? icon(for: mode) + ".fill" : icon(for: mode))
.font(.system(size: 22))
.foregroundStyle(isActive ? .primary : .secondary)
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.bottom, 14)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(mode.rawValue.capitalized)")
}
private func icon(for mode: ProfileViewMode) -> String {
switch mode {
case .grid: "square.grid.3x3"
case .favorites: "heart"
case .stories: "clock"
}
}
}
/// Sizes the pager to the blend of its pages' heights at the current swipe
/// offset. A modifier rather than a `frame` in the parent's body so that the
/// per-frame read of the offset re-evaluates this, not the pages under it.
struct ProfilePagerHeight: ViewModifier {
let pager: ProfilePagerState
let selected: ProfileViewMode
func body(content: Content) -> some View {
content.frame(height: pager.interpolatedHeight(selected: selected), alignment: .top)
}
}