Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Motion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public enum StrandMotion {
/// Slow / draw-in (ring arc, waveform ignite).
public static let durationSlow: Double = 0.9

/// Deliberately slower restoration for scroll-reactive navigation — the bar returns calmly rather than popping in.
public static let durationNavigationExpand: Double = 0.42

/// One breath cycle for ambient pulsing (bloom, listening flatline).
public static let breathPeriod: Double = 3.2

Expand Down Expand Up @@ -63,6 +66,12 @@ public enum StrandMotion {

/// Standard fade.
public static let fade = Animation.easeInOut(duration: durationStandard)

/// Smooth, non-bouncy contraction after scroll-reactive navigation crosses its compact threshold.
public static let navigationBarCollapse = Animation.easeInOut(duration: durationStandard)

/// A deliberately slower restoration so the full bar returns calmly instead of appearing to pop in.
public static let navigationBarExpand = Animation.easeInOut(duration: durationNavigationExpand)
}

#if DEBUG
Expand Down
25 changes: 23 additions & 2 deletions Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ struct LiquidTodayView: View {
@EnvironmentObject var router: NavRouter
@EnvironmentObject var profile: ProfileStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion
#if os(iOS)
@Environment(\.tabBarScrollTag) private var tabBarScrollTag
@Environment(\.tabBarScrollInteractionChanged) private var tabBarScrollInteractionChanged
#endif

/// Shared with the real Today's card-customise editor so the two stay in sync.
@AppStorage(DashboardCardPrefs.selectionKey) private var dashboardCardsRaw = ""
Expand Down Expand Up @@ -191,8 +195,16 @@ struct LiquidTodayView: View {
// Scroll-offset probe at the very top (before padding), so its minY in the scroll's
// coordinate space reads the top OVERSCROLL: ~0 at rest, positive as you pull down.
GeometryReader { g in
let offset = g.frame(in: .named(Self.pullSpace)).minY
#if os(iOS)
Color.clear
.preference(key: PullOffsetKey.self, value: offset)
.preference(key: TabBarScrollOffsetKey.self,
value: tabBarScrollTag.map { [$0: offset] } ?? [:])
#else
Color.clear.preference(key: PullOffsetKey.self,
value: g.frame(in: .named(Self.pullSpace)).minY)
value: offset)
#endif
}
.frame(height: 0)

Expand Down Expand Up @@ -220,7 +232,16 @@ struct LiquidTodayView: View {
#endif
}
.coordinateSpace(name: Self.pullSpace)
.onPreferenceChange(PullOffsetKey.self) { handlePull($0) }
#if os(iOS)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in tabBarScrollInteractionChanged(true) }
.onEnded { _ in tabBarScrollInteractionChanged(false) }
)
#endif
.onPreferenceChange(PullOffsetKey.self) { offset in
handlePull(offset)
}
// The sky is a FIXED full-bleed backdrop drawn behind the scroll content, edge-to-edge under the
// status bar. A ScrollView background does not scroll with the content, so pulling down never
// moves the sky (the exact behaviour the scaffold uses on the classic Today).
Expand Down
73 changes: 73 additions & 0 deletions Strand/Screens/ScreenScaffold.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,59 @@
import SwiftUI
import StrandDesign

#if os(iOS)
enum BottomBarBehavior: String, CaseIterable, Identifiable {
static let storageKey = "noop.bottomBarBehavior"

case reactive
case expanded
case compact

var id: String { rawValue }
var label: LocalizedStringKey {
switch self {
case .reactive: "Reactive"
case .expanded: "Expanded"
case .compact: "Compact"
}
}
}

private struct TabBarScrollTagKey: EnvironmentKey {
static let defaultValue: Int? = nil
}

private struct TabBarScrollInteractionChangedKey: EnvironmentKey {
static let defaultValue: (Bool) -> Void = { _ in }
}

extension EnvironmentValues {
var tabBarScrollTag: Int? {
get { self[TabBarScrollTagKey.self] }
set { self[TabBarScrollTagKey.self] = newValue }
}

var tabBarScrollInteractionChanged: (Bool) -> Void {
get { self[TabBarScrollInteractionChangedKey.self] }
set { self[TabBarScrollInteractionChangedKey.self] = newValue }
}
}

/// Scrollable tab roots publish their content offset to the iPhone shell, which owns the floating bar.
/// Values stay keyed by tab because `TabView` retains its off-screen roots; reducing them to one scalar lets
/// a hidden tab's zero overwrite the visible tab's moving offset.
struct TabBarScrollOffsetKey: PreferenceKey {
static var defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}

private enum TabBarScrollSpace {
static let name = "noop.tabBarScroll"
}
#endif

/// Standard scrollable screen container: title + dark surface + content column.
struct ScreenScaffold<Content: View, Trailing: View>: View {
/// Optional — when nil (and no subtitle) the header is omitted entirely, so a screen can supply its
Expand Down Expand Up @@ -31,6 +84,8 @@ struct ScreenScaffold<Content: View, Trailing: View>: View {
// by `#if os(iOS)` — a runtime size-class check alone would also narrow the Mac detail pane.
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.tabBarScrollTag) private var tabBarScrollTag
@Environment(\.tabBarScrollInteractionChanged) private var tabBarScrollInteractionChanged
#endif

var body: some View {
Expand All @@ -49,6 +104,18 @@ struct ScreenScaffold<Content: View, Trailing: View>: View {
.frame(maxWidth: hSizeClass == .regular ? 700 : .infinity,
alignment: hSizeClass == .regular ? .center : .leading)
.frame(maxWidth: .infinity, alignment: .center)
// Read the content's real position in its scroll viewport. The root shell accumulates these
// offsets before changing state, so bounce and one-pixel settling never flicker the tab bar.
.background {
GeometryReader { geometry in
Color.clear.preference(
key: TabBarScrollOffsetKey.self,
value: tabBarScrollTag.map {
[$0: geometry.frame(in: .named(TabBarScrollSpace.name)).minY]
} ?? [:]
)
}
}
#else
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
Expand All @@ -59,6 +126,12 @@ struct ScreenScaffold<Content: View, Trailing: View>: View {
// permits horizontal bounce when content genuinely overflows the width (it does not here, the column
// is width-capped), so the spurious horizontal rubber-band that caused the sideways drift is gone.
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.coordinateSpace(name: TabBarScrollSpace.name)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in tabBarScrollInteractionChanged(true) }
.onEnded { _ in tabBarScrollInteractionChanged(false) }
)
#endif
// The flat canvas, plus an optional full-bleed TOP backdrop (Today's day-cycle scene) drawn behind
// the scroll content — edge-to-edge under the status bar. The scene is CONFINED to the header+hero
Expand Down
21 changes: 21 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ struct SettingsView: View {
@AppStorage("appIcon.alt") private var useNavyIcon = false
// Light/Dark/System theme. Read by both app roots' .preferredColorScheme; default follows the OS.
@AppStorage(AppearanceMode.storageKey) private var appearanceRaw = AppearanceMode.system.rawValue
#if os(iOS)
// iPhone floating navigation: react to scroll by default, or pin either resting state.
@AppStorage(BottomBarBehavior.storageKey) private var bottomBarBehaviorRaw = BottomBarBehavior.reactive.rawValue
#endif
// Chart colour style: Titanium (brand) or Classic (throwback red→green). Re-colours gauges + charts.
@AppStorage(ChartStyle.storageKey) private var chartStyleRaw = ChartStyle.titanium.rawValue
// Day-cycle scene backdrop behind Today (#698). Default ON. Off swaps the scene for a plain dark
Expand Down Expand Up @@ -670,6 +674,23 @@ struct SettingsView: View {
.accessibilityLabel("Chart colours")
}
#if os(iOS)
FormRow(label: "Bottom bar") {
Picker("Bottom bar", selection: $bottomBarBehaviorRaw) {
ForEach(BottomBarBehavior.allCases) { behavior in
Text(behavior.label).tag(behavior.rawValue)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.fixedSize()
.accessibilityLabel("Bottom bar")
}
Text("Reactive subtly compacts while scrolling. Expanded and Compact keep the chosen size at all times.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)

FormRow(label: "App icon") {
Picker("App icon", selection: $useNavyIcon) {
Text("Default").tag(false)
Expand Down
Loading