Skip to content
Open
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
7 changes: 7 additions & 0 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ This rule follows the typed-query pattern proven by TheButtonHeist's
audited and retained: Where needs the same check plus explicit exceptions for
its documented observation-task lifecycle.

## Reserved-region layout ownership

`where.reserved_region_layout_ownership` permits `reservedRegions` calls only in `AdaptivePaneGeometry` within WhereUI.
Views use that adapter or `AdaptivePaneContainer`. Keep the system margins and physical coordinate mapping there.
The syntax guard checks member-call spelling; it does not resolve aliases or function references.
`WhereProjectRulesTests` covers direct member-call mutations.

## Composition ownership

`where.services_composition_ownership` keeps direct `WhereServices`
Expand Down
26 changes: 26 additions & 0 deletions .bumper/Sources/WhereProjectRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let whereProjectRules = RuleSet {
owner: whereLoggingScope.union(nonWhereProductionScope),
id: "where.logging_type_ownership",
)
reservedRegionLayoutOwnershipRule
productionStoreOpeningRule
checkedConcurrencyBoundaryRule
gregorianCalendarRule
Expand Down Expand Up @@ -305,3 +306,28 @@ private let previewCoverageRule = Rules.files(
)
}
}

/// Keep system geometry mapping in one adapter, shared by production and injected fixtures.
private let reservedRegionLayoutOwnershipRule = Rules.files(
"where.reserved_region_layout_ownership",
severity: .error,
summary: "WhereUI reads system reserved regions through its shared geometry adapter.",
scope: .component(WhereComponent.whereUI),
) { file in
guard file.path.rawValue != "Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift"
else { return [] }
return functionCalls()
.filter { match in
match.node.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.text == "reservedRegions"
}
.matches(in: file)
.map { match in
match.failure(
message: "Reserved regions are read outside AdaptivePaneGeometry.",
evidence: ViolationEvidence(
observed: file.path.rawValue,
expectation: "use AdaptivePaneGeometry or AdaptivePaneContainer",
),
)
}
}
17 changes: 17 additions & 0 deletions .bumper/Tests/WhereProjectRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import BumperBowlingTestSupport
import Testing

struct WhereProjectRulesTests {
@Test
func `system reserved regions use one geometry adapter`() throws {
let source = "let regions = proxy.reservedRegions(kind: .division, options: .includeInactive, layoutDirectionBehavior: .fixed)"
let allowed = try evaluate(
path: "Where/WhereUI/Sources/Shared/AdaptivePaneGeometry.swift",
component: .whereUI,
source: source,
)
let rejected = try evaluate(
path: "Where/WhereUI/Sources/Primary/LocationsView.swift",
component: .whereUI,
source: source,
)
#expect(allowed.violations.isEmpty)
#expect(rejected.violations.map(\.rule.id) == ["where.reserved_region_layout_ownership"])
}

@Test
func `production store opens at process composition roots`() throws {
let allowed = try evaluate(
Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ let package = Package(
.target(
name: "SnapshotKit",
path: "Shared/SnapshotKit/Sources",
resources: [.process("Resources")],
),
.target(
name: "SnapshotKitTesting",
Expand Down
6 changes: 6 additions & 0 deletions Shared/Broadway/BroadwayUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](.
- **Mirror the value into the UIKit trait system** so nested UIKit views receive it.
- **If none is set, fall back to the UIKit trait-bridged value.** Then a `BRootViewController`-set context still reaches SwiftUI.

## Scroll restoration

- Keep scroll restoration opt-in through an explicit stable scope. Key entries by view type and typed role; a context change replaces the entry, not its value in a historical cache.
- Resolve a mount again only when its scope, key, or context changes. Capture the resolved entry in native observable bindings. Outgoing views and obsolete contexts must not write into a replacement entry. Guard: `BScrollPositionTests`.
- Views own initial positioning and missing-target policy. The shared store must not import application types or synchronize panes.

## Testing

Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`).
32 changes: 32 additions & 0 deletions Shared/Broadway/BroadwayUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,38 @@ Later explicit values update the context normally.
`bHorizontalSizeClass(_:)` and `bVerticalSizeClass(_:)` scope Broadway layout policies to a subtree.
These overrides affect stylesheets; native container traits remain under system control.

## Scroll restoration

`BScrollPosition` exposes a native `ScrollPosition` binding. Add
`.bScrollRestorationScope()` to a stable ancestor to retain positions when its
descendants are replaced. A caller-owned `BScrollRestorationStore` can also be
supplied to the modifier. Without a scope, positions remain local to the view.

Initialize the wrapper with the view type, a typed role, a context token, and the
scroll target ID type. Independent panes need independent roles or scopes.
Changing context (for example, region or year) discards the previous position;
returning to that context starts fresh. Old bindings retain their detached entry
and cannot overwrite its replacement. Stores belong to one scene/session.

Views choose initial anchors, wait for content, and handle missing targets.
Call `_position.initialize(id: target, anchor: .top)` or `.initialize(edge: .top)`
from a view task after content is ready. The first call restores the retained target for this mount,
or applies the initial policy for a new context. Later calls in the same mount are no-ops.
A pending restoration protects the previous target from provisional layout updates. User movement supersedes that pending restoration.

Use `_position.scrollTo(id: target, anchor: .bottom)` or `.scrollTo(edge: .top)` for
explicit commands. These retain alignment, which `ScrollPosition` does not expose
for reading. The native binding still supports immediate scrolling, but commands
through that binding cannot retain non-default alignment. User scrolling adopts
top alignment. The read-only `anchor` property supplies transition geometry.
The wrapper retains the concrete context type until the heterogeneous store boundary.
A context closure can read a live observable input when the owning view updates.
Reading an unmounted wrapper asserts in debug builds; release builds use an isolated
fallback that cannot access or replace a mounted scope entry.
The store neither interprets application data nor synchronizes separate panes.
Use identified content and alignment for restoration; pixel offsets are not a
cross-layout restoration contract. Guards: `BScrollPositionTests`.

## Install

Local SPM library declared in the root [`Package.swift`](../../../Package.swift) (depends on BroadwayCore): `.package(product: "BroadwayUI")`.
Expand Down
176 changes: 176 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BScrollPosition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import SwiftUI

/// A native scroll binding retained by the nearest explicit restoration scope.
/// Use distinct roles for independent panes. Context changes discard the previous position.
@MainActor
@propertyWrapper
public struct BScrollPosition<
ID: Hashable & Sendable,
Context: Hashable,
>: @MainActor DynamicProperty {
@Environment(\.bScrollRestorationStore) private var sharedStore
@State private var localStore = BScrollRestorationStore()
private let key: BScrollRestorationStore.Key
private let context: () -> Context
@State private var mount = Mount()
private var resolvedAttachment: Attachment?
/// Unmounted access never joins a scope. Copies of this wrapper retain the same fallback.
private let unmountedAttachment = Attachment(entry: .init())

private struct Restoration {
let position: ScrollPosition
let anchor: UnitPoint?
}

private final class Attachment {
let entry: BScrollRestorationStore.Entry<ID>
var pending: Restoration?
let userRevision: Int

init(entry: BScrollRestorationStore.Entry<ID>) {
self.entry = entry
userRevision = entry.userRevision
pending = Restoration(position: entry.position, anchor: entry.anchor)
}
}

@MainActor
private final class Mount {
private var store: BScrollRestorationStore?
private var context: Context?
private var key: BScrollRestorationStore.Key?
private var attachment: Attachment?

func resolve(
store: BScrollRestorationStore,
key: BScrollRestorationStore.Key,
context: Context,
) -> Attachment {
if self.store === store, self.key == key, self.context == context,
let attachment { return attachment }
let attachment = Attachment(entry: store.entry(
for: key,
context: AnyHashable(context),
idType: ID.self,
))
self.store = store
self.key = key
self.context = context
self.attachment = attachment
return attachment
}
}

public init(
_ view: (some View).Type,
role: some Hashable,
context: Context,
idType: ID.Type,
) {
key = .init(
view: ObjectIdentifier(view),
role: AnyHashable(role),
content: ObjectIdentifier(idType),
)
self.context = { context }
}

/// Resolve a context supplied by an observable input when the owning view updates.
public init(
_ view: (some View).Type,
role: some Hashable,
context: @escaping @MainActor () -> Context,
idType: ID.Type,
) {
key = .init(
view: ObjectIdentifier(view),
role: AnyHashable(role),
content: ObjectIdentifier(idType),
)
self.context = context
}

public mutating func update() {
// An outgoing view can still render while its replacement is mounted.
// Only a new mount, scope, or context may claim the store's current entry.
resolvedAttachment = mount.resolve(
store: sharedStore ?? localStore,
key: key,
context: context(),
)
}

private var attachment: Attachment {
guard let resolvedAttachment else {
assertionFailure("BScrollPosition must be read from a mounted view")
return unmountedAttachment
}
return resolvedAttachment
}

private var entry: BScrollRestorationStore.Entry<ID> {
attachment.entry
}

/// Alignment used for semantic restoration; native user scrolling adopts top alignment.
public var anchor: UnitPoint? {
entry.anchor
}

public var wrappedValue: ScrollPosition {
get { entry.position }
nonmutating set { entry.position = newValue }
}

public var projectedValue: Binding<ScrollPosition> {
// Preserve SwiftUI's observable binding transaction, and capture this entry
// so an outgoing view cannot redirect a write into a replacement context.
@Bindable var entry = entry
return $entry.position
}

/// Restore once per mount when content is ready, or apply this context's initial policy.
public func initialize(id: ID, anchor: UnitPoint) {
initialize(position: ScrollPosition(id: id, anchor: anchor), anchor: anchor)
}

public func initialize(edge: Edge) {
initialize(position: ScrollPosition(idType: ID.self, edge: edge), anchor: nil)
}

private func initialize(position: ScrollPosition, anchor: UnitPoint?) {
let attachment = attachment
guard let restoration = attachment.pending else { return }
attachment.pending = nil
guard attachment.userRevision == entry.userRevision else { return }
// Mutate the live position so repeated semantic commands notify its native binding.
if entry.isInitialized, let edge = restoration.position.edge {
entry.position.scrollTo(edge: edge)
entry.anchor = nil
} else if entry.isInitialized,
let target = restoration.position.viewID(type: ID.self)
{
entry.position.scrollTo(id: target, anchor: restoration.anchor)
entry.anchor = restoration.anchor
} else {
entry.position = position
entry.anchor = anchor
}
entry.isInitialized = true
}

/// Use semantic commands to retain alignment, which native ScrollPosition does not expose.
public func scrollTo(id: ID, anchor: UnitPoint) {
attachment.pending = nil
entry.isInitialized = true
entry.anchor = anchor
entry.position.scrollTo(id: id, anchor: anchor)
}

public func scrollTo(edge: Edge) {
attachment.pending = nil
entry.isInitialized = true
entry.anchor = nil
entry.position.scrollTo(edge: edge)
}
}
21 changes: 21 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BScrollRestorationScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import SwiftUI

private struct BScrollRestorationScope: ViewModifier {
@State private var store = BScrollRestorationStore()

func body(content: Content) -> some View {
content.environment(\.bScrollRestorationStore, store)
}
}

extension View {
/// Own a restoration store above content that can be replaced during layout changes.
public func bScrollRestorationScope() -> some View {
modifier(BScrollRestorationScope())
}

/// Share a caller-owned store across replacement hosts within the same scene/session.
public func bScrollRestorationScope(_ store: BScrollRestorationStore) -> some View {
environment(\.bScrollRestorationStore, store)
}
}
56 changes: 56 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BScrollRestorationStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Observation
import SwiftUI

/// Retains each scroll role's current context. Replacing a context detaches its old bindings.
@MainActor
public final class BScrollRestorationStore {
struct Key: Hashable {
let view: ObjectIdentifier
let role: AnyHashable
let content: ObjectIdentifier
}

private struct Record {
let context: AnyHashable
let entry: AnyObject
}

@Observable
final class Entry<ID: Hashable & Sendable> {
var position = ScrollPosition(idType: ID.self) {
didSet {
guard oldValue != position, position.isPositionedByUser else { return }
anchor = .top
isInitialized = true
userRevision += 1
}
}

private(set) var userRevision = 0
var isInitialized = false
var anchor: UnitPoint?
}

private var records: [Key: Record] = [:]

public init() {}

func entry<ID: Hashable & Sendable>(
for key: Key,
context: AnyHashable,
idType _: ID.Type,
) -> Entry<ID> {
if let record = records[key], record.context == context,
let entry = record.entry as? Entry<ID>
{
return entry
}
let entry = Entry<ID>()
records[key] = Record(context: context, entry: entry)
return entry
}
}

extension EnvironmentValues {
@Entry var bScrollRestorationStore: BScrollRestorationStore?
}
Loading
Loading