Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Shared/Broadway/BroadwayCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](.

- Select `BModeVarying` values with the slice's explicit mode. Keep product theme definitions in consumer modules.

- **Keep both size classes registered in `BTraits.system`.** Expose only compact and regular values. Normalize unavailable UIKit values to compact; do not infer size classes from device identity or measured bounds.

## Testing

Run `BroadwayCoreTests` in `StuffTestHost` (`./test BroadwayCoreTests`).
4 changes: 4 additions & 0 deletions Shared/Broadway/BroadwayCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ let opacity = BModeVarying(light: 0.14, dark: 0.3)
let resolvedOpacity = opacity[context.traits.mode]
```

`BHorizontalSizeClass` and `BVerticalSizeClass` expose only compact and regular values.
Both default to compact. Their UIKit initializers map unspecified and unknown values to compact, so styling consumers need only two cases.
`BTraits.system` registers both for live UIKit observation. Scoped overrides use the matching properties on `BTraits.Overrides`.

## How it works

`BContext` holds a `BStylesheets` cache that is rebuilt whenever `baseTraits`, `traitOverrides`, or `themes` change (via `didSet`).
Expand Down
2 changes: 2 additions & 0 deletions Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable {
traits.register(BAccessibility.self)
traits.register(BMode.self)
traits.register(BContentSizeCategory.self)
traits.register(BHorizontalSizeClass.self)
traits.register(BVerticalSizeClass.self)

return traits
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import UIKit

/// The horizontal space available to a component. Unavailable system values use compact styling.
public enum BHorizontalSizeClass: Hashable, Sendable {
case compact
case regular

public init(_ sizeClass: UIUserInterfaceSizeClass) {
self = switch sizeClass {
case .unspecified: .compact
case .compact: .compact
case .regular: .regular
@unknown default: .compact
}
}
}

extension BHorizontalSizeClass: BTraitsValue {
public static let defaultValue: Self = .compact

@MainActor public static func currentValue(from viewController: UIViewController) -> Self {
Self(viewController.traitCollection.horizontalSizeClass)
}

@MainActor public static func makeObserver(
with viewController: UIViewController,
onChange: @MainActor @escaping @Sendable (Self) -> Void,
) -> UIViewControllerTraitObserver {
UIViewControllerTraitObserver(
observing: [UITraitHorizontalSizeClass.self],
on: viewController,
) { controller in
onChange(currentValue(from: controller))
}
}
}

extension BTraits {
public var horizontalSizeClass: BHorizontalSizeClass {
get { self[BHorizontalSizeClass.self] }
set { self[BHorizontalSizeClass.self] = newValue }
}
}

extension BTraits.Overrides {
public var horizontalSizeClass: BHorizontalSizeClass? {
get { self[BHorizontalSizeClass.self] }
set { self[BHorizontalSizeClass.self] = newValue }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import UIKit

/// The vertical space available to a component. Unavailable system values use compact styling.
public enum BVerticalSizeClass: Hashable, Sendable {
case compact
case regular

public init(_ sizeClass: UIUserInterfaceSizeClass) {
self = switch sizeClass {
case .unspecified: .compact
case .compact: .compact
case .regular: .regular
@unknown default: .compact
}
}
}

extension BVerticalSizeClass: BTraitsValue {
public static let defaultValue: Self = .compact

@MainActor public static func currentValue(from viewController: UIViewController) -> Self {
Self(viewController.traitCollection.verticalSizeClass)
}

@MainActor public static func makeObserver(
with viewController: UIViewController,
onChange: @MainActor @escaping @Sendable (Self) -> Void,
) -> UIViewControllerTraitObserver {
UIViewControllerTraitObserver(
observing: [UITraitVerticalSizeClass.self],
on: viewController,
) { controller in
onChange(currentValue(from: controller))
}
}
}

extension BTraits {
public var verticalSizeClass: BVerticalSizeClass {
get { self[BVerticalSizeClass.self] }
set { self[BVerticalSizeClass.self] = newValue }
}
}

extension BTraits.Overrides {
public var verticalSizeClass: BVerticalSizeClass? {
get { self[BVerticalSizeClass.self] }
set { self[BVerticalSizeClass.self] = newValue }
}
}
32 changes: 32 additions & 0 deletions Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@testable import BroadwayCore
import TestHostSupport
import Testing
import UIKit

@MainActor
struct BHorizontalSizeClassTests {
@Test func mapsUIKitValuesAndDefaultsToCompact() {
#expect(BHorizontalSizeClass(.unspecified) == .compact)
#expect(BHorizontalSizeClass(.compact) == .compact)
#expect(BHorizontalSizeClass(.regular) == .regular)
#expect(BTraits().horizontalSizeClass == .compact)
#expect(BTraits.system.horizontalSizeClass == .compact)
}

@Test func observesLiveChangesAndScopedOverrides() throws {
let host = UIViewController()
host.traitOverrides.horizontalSizeClass = .compact
try show(host) { controller in
let observer = BTraitsObserver(traits: .system, from: controller, onChange: { _ in })
observer.start()
defer { observer.stop() }
#expect(observer.traits.horizontalSizeClass == .compact)
controller.traitOverrides.horizontalSizeClass = .regular
try waitFor { observer.traits.horizontalSizeClass == .regular }
var overrides = BTraits.Overrides()
overrides.horizontalSizeClass = .compact
#expect(observer.traits.merging(with: overrides).horizontalSizeClass == .compact)
#expect(observer.traits.horizontalSizeClass == .regular)
}
}
}
32 changes: 32 additions & 0 deletions Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@testable import BroadwayCore
import TestHostSupport
import Testing
import UIKit

@MainActor
struct BVerticalSizeClassTests {
@Test func mapsUIKitValuesAndDefaultsToCompact() {
#expect(BVerticalSizeClass(.unspecified) == .compact)
#expect(BVerticalSizeClass(.compact) == .compact)
#expect(BVerticalSizeClass(.regular) == .regular)
#expect(BTraits().verticalSizeClass == .compact)
#expect(BTraits.system.verticalSizeClass == .compact)
}

@Test func observesLiveChangesAndScopedOverrides() throws {
let host = UIViewController()
host.traitOverrides.verticalSizeClass = .compact
try show(host) { controller in
let observer = BTraitsObserver(traits: .system, from: controller, onChange: { _ in })
observer.start()
defer { observer.stop() }
#expect(observer.traits.verticalSizeClass == .compact)
controller.traitOverrides.verticalSizeClass = .regular
try waitFor { observer.traits.verticalSizeClass == .regular }
var overrides = BTraits.Overrides()
overrides.verticalSizeClass = .compact
#expect(observer.traits.merging(with: overrides).verticalSizeClass == .compact)
#expect(observer.traits.verticalSizeClass == .regular)
}
}
}
3 changes: 2 additions & 1 deletion Shared/Broadway/BroadwayUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](.
- **Keep shared components here only.** Put app-specific views in BroadwayCatalog.
- **Defer `BRootViewController` setup** until the controller enters a valid hierarchy (`viewIsAppearing`). Before then, `context` is `nil`.
- **The controller publishes context to descendants through `traitOverrides.bContext`.**
- **Do not add `BTraitsObserver` to `BRootView`.** SwiftUI re-evaluates `body` on color-scheme and Dynamic Type changes.
- **Do not add `BTraitsObserver` to `BRootView`.** SwiftUI re-evaluates `body` on color-scheme, Dynamic Type, and size-class changes.
- **A `.task` mirrors `BAccessibility.changes()` into state.** Both rebuild the injected `BContext`.
- **Context-building lives in `BRootContext.make(...)`.** That makes the trait mapping testable without a host.
- **Map unavailable SwiftUI size classes to compact on both axes.** Keep this consistent with BroadwayCore's UIKit mapping.
- **Make `\.bContext` prefer a synchronous SwiftUI value, and mirror to UIKit.**
- **`BContext+SwiftUI` stores a SwiftUI-set context in a pure-SwiftUI `EnvironmentKey`.** That covers `BRootView`, `broadwayRoot`, and `bTraitOverrides`.
- **Read it synchronously.** Do not round-trip through `UITraitCollection`. Do not accept first-frame lag.
Expand Down
8 changes: 7 additions & 1 deletion Shared/Broadway/BroadwayUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ UIKit + SwiftUI components that carry a `BContext` (from BroadwayCore) through t
## Public API

- **`BRootViewController`** — a container view controller that owns the root `BContext`, observes system trait changes via `BTraitsObserver`, and republishes the context to descendants through `traitOverrides`. The designated initializer wraps any `UIViewController`. A convenience initializer hosts SwiftUI content directly.
- **`BRootView` / `.broadwayRoot(themes:)`** — the SwiftUI-native root. Seeds a root `BContext` from the live system traits (`@Environment(\.colorScheme)`, `\.dynamicTypeSize`, and `BAccessibility.changes()`) plus the given `themes`, and injects it so descendants read `@Environment(\.bContext)`. Unlike the UIKit root it takes `themes`, letting an app seed palette/typography at the root.
- **`BRootView` / `.broadwayRoot(themes:)`** — the SwiftUI-native root. Seeds a root `BContext` from the live system traits (`@Environment(\.colorScheme)`, `\.dynamicTypeSize`, both size classes, and `BAccessibility.changes()`) plus the given `themes`, and injects it so descendants read `@Environment(\.bContext)`. Unlike the UIKit root it takes `themes`, letting an app seed palette/typography at the root.
- **`BTraitOverridesViewController`** — scopes trait overrides to a subtree while preserving inherited base traits and themes.
- **SwiftUI bridges** — `BContext+SwiftUI` resolves `@Environment(\.bContext)`, preferring a synchronous pure-SwiftUI value (set by `BRootView` / `broadwayRoot` / `bTraitOverrides`, and mirrored into UIKit traits so it also reaches nested UIKit) and falling back to the UIKit trait-bridged value when none is set. Plus `BTraitOverrides+SwiftUI` and the `BMode` / `BContentSizeCategory` initializers from `ColorScheme` / `DynamicTypeSize` (`BTraitValues+SwiftUI`).

Expand All @@ -30,6 +30,12 @@ WindowGroup {
}
```

The root observes horizontal and vertical size classes through SwiftUI.
Missing or unknown size classes resolve to compact on both axes, matching the UIKit bridge.
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.

## Install

Local SPM library declared in the root [`Package.swift`](../../../Package.swift) (depends on BroadwayCore): `.package(product: "BroadwayUI")`.
Expand Down
8 changes: 8 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public struct BRootView<Content: View>: View {

@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var accessibility: BAccessibility = .current()

public init(themes: BThemes = .init(), @ViewBuilder content: () -> Content) {
Expand All @@ -35,6 +37,8 @@ public struct BRootView<Content: View>: View {
.environment(\.bContext, BRootContext.make(
colorScheme: colorScheme,
dynamicTypeSize: dynamicTypeSize,
horizontalSizeClass: horizontalSizeClass,
verticalSizeClass: verticalSizeClass,
accessibility: accessibility,
themes: themes,
))
Expand Down Expand Up @@ -65,6 +69,8 @@ enum BRootContext {
static func make(
colorScheme: ColorScheme,
dynamicTypeSize: DynamicTypeSize,
horizontalSizeClass: UserInterfaceSizeClass?,
verticalSizeClass: UserInterfaceSizeClass?,
accessibility: BAccessibility,
themes: BThemes,
) -> BContext {
Expand All @@ -79,6 +85,8 @@ enum BRootContext {
// there instead when a token needs to follow a scoped scheme override.
traits.mode = BMode(colorScheme)
traits.contentSizeCategory = BContentSizeCategory(dynamicTypeSize)
traits.horizontalSizeClass = BHorizontalSizeClass(horizontalSizeClass)
traits.verticalSizeClass = BVerticalSizeClass(verticalSizeClass)
traits.accessibility = accessibility
return BContext(traits: traits, themes: themes)
}
Expand Down
10 changes: 10 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ extension View {
public func bContentSizeCategory(_ category: BContentSizeCategory) -> some View {
bTraitOverrides { _, overrides in overrides.contentSizeCategory = category }
}

/// Overrides the horizontal layout trait for this subtree's stylesheets.
public func bHorizontalSizeClass(_ sizeClass: BHorizontalSizeClass) -> some View {
bTraitOverrides { _, overrides in overrides.horizontalSizeClass = sizeClass }
}

/// Overrides the vertical layout trait for this subtree's stylesheets.
public func bVerticalSizeClass(_ sizeClass: BVerticalSizeClass) -> some View {
bTraitOverrides { _, overrides in overrides.verticalSizeClass = sizeClass }
}
}
24 changes: 24 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,27 @@ extension BContentSizeCategory {
}
}
}

extension BHorizontalSizeClass {
/// Maps SwiftUI's size class, using compact styling when the value is unavailable.
public init(_ sizeClass: UserInterfaceSizeClass?) {
self = switch sizeClass {
case .compact: .compact
case .regular: .regular
case nil: .compact
@unknown default: .compact
}
}
}

extension BVerticalSizeClass {
/// Maps SwiftUI's size class, using compact styling when the value is unavailable.
public init(_ sizeClass: UserInterfaceSizeClass?) {
self = switch sizeClass {
case .compact: .compact
case .regular: .regular
case nil: .compact
@unknown default: .compact
}
}
}
22 changes: 21 additions & 1 deletion Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ struct BRootContextTests {
let context = BRootContext.make(
colorScheme: .dark,
dynamicTypeSize: .accessibility3,
horizontalSizeClass: .regular,
verticalSizeClass: .compact,
accessibility: BAccessibility(isVoiceOverRunning: true),
themes: themes,
)

#expect(context.traits.mode == .dark)
#expect(context.traits.horizontalSizeClass == .regular)
#expect(context.traits.verticalSizeClass == .compact)
#expect(context.traits.contentSizeCategory == .accessibilityExtraLarge)
#expect(context.traits.accessibility == BAccessibility(isVoiceOverRunning: true))
#expect(context.themes[Marker.self] == .b)
Expand All @@ -33,6 +37,22 @@ struct BRootContextTests {

@MainActor
struct BRootViewHostingTests {
@Test("A mounted root follows repeated size-class changes without replacing content")
func followsResizing() throws {
let box = ContextBox()
let host = UIHostingController(rootView: BRootView { ContextProbe(box: box) })
try show(host) { controller in
for sizeClass in [UIUserInterfaceSizeClass.compact, .regular, .compact] {
controller.traitOverrides.horizontalSizeClass = sizeClass
controller.traitOverrides.verticalSizeClass = .regular
try waitFor {
box.value?.traits.horizontalSizeClass == BHorizontalSizeClass(sizeClass)
&& box.value?.traits.verticalSizeClass == .regular
}
}
}
}

@Test("Root injects its themes into the descendant environment")
func injectsThemes() throws {
var themes = BThemes()
Expand Down Expand Up @@ -63,6 +83,6 @@ private struct ContextProbe: View {
@Environment(\.bContext) private var context

var body: some View {
Color.clear.onAppear { box.value = context }
Color.clear.onChange(of: context, initial: true) { _, value in box.value = value }
}
}
11 changes: 11 additions & 0 deletions Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,14 @@ struct BContentSizeCategorySwiftUIMappingTests {
#expect(BContentSizeCategory(.accessibility5) == .accessibilityExtraExtraExtraLarge)
}
}

struct BSizeClassSwiftUIMappingTests {
@Test func mapsSizeClassesAndDefaultsToCompact() {
#expect(BHorizontalSizeClass(UserInterfaceSizeClass.compact) == .compact)
#expect(BHorizontalSizeClass(UserInterfaceSizeClass.regular) == .regular)
#expect(BHorizontalSizeClass(nil) == .compact)
#expect(BVerticalSizeClass(UserInterfaceSizeClass.compact) == .compact)
#expect(BVerticalSizeClass(UserInterfaceSizeClass.regular) == .regular)
#expect(BVerticalSizeClass(nil) == .compact)
}
}
Loading