Skip to content

Add NavigationStack, TabView, sheet, refreshable, lazy stacks and Observation support - #4

Merged
colemancda merged 4 commits into
masterfrom
feature/swiftui-containers
Jul 21, 2026
Merged

colemancda merged 4 commits into
masterfrom
feature/swiftui-containers

Conversation

@colemancda

Copy link
Copy Markdown
Member

Adds the SwiftUI surface a real app needs: NavigationStack, TabView, .sheet, .refreshable,
lazy stacks, and Swift Observation support.

Motivation: migrating BluetoothExplorer
off Skip onto AndroidSwiftUI. Its UI compiled against the system SwiftUI but hit six gaps here; this
closes all of them, so the same view code now builds for both platforms behind
#if canImport(SwiftUI).

What's added

NavigationStack — reuses the existing NavigationContext rather than duplicating navigation.
NavigationLink push and hardware-back pop are shared with NavigationView: on the Android side
AndroidNavigationContainer was de-genericised so both containers drive the same BackHandlerView.

TabView with .tabItem and selection. Tab item labels are written through the existing
_ViewTraitKey machinery (the same mechanism .tag() uses). Because the Android side works from
ParentView.children and never sees the mounted trait store, _TabViewProxy walks each tab's
modifier chain and reads the values straight off the _TraitWritingModifiers — which required a
small internal _AnyModifiedContent conformance on ModifiedContent to erase the generics. Only the
selected tab is mounted; the content and tab-bar containers are deliberately non-generic so
switching updates them in place rather than remounting (AndroidRenderer.mountTarget always
appends, so a remount would push content below the bar).

.sheet(isPresented:onDismiss:content:) plus the item: overload — implemented as a
full-screen overlay in the same view hierarchy, not an Android Dialog. The fiber renderer mounts
children by addView into the parent's ViewGroup and has no path to a Dialog's separate decor
view. BackHandlerView is already a FrameLayout, so it doubles as the container and gives
back-button dismissal for free.

.refreshable — stores a RefreshAction in the environment exactly as SwiftUI does, and accepts
async closures. No gesture triggers it yet: binding SwipeRefreshLayout would need an androidx
dependency this package doesn't have. Code reading @Environment(\.refresh) works today.

LazyVStack / LazyHStack — eager, mapping onto the same LinearLayout path as VStack/HStack.
Laziness is an optimisation, not a semantic requirement; noted in the source.

Swift Observation.environment(object) and @Environment(Type.self) for
AnyObject & Observable. Invalidation wraps the three render entry points in
withObservationTracking, so any property read while producing a body is registered; onChange
funnels through scheduler into the same queueUpdate path objectWillChange already uses, which
keeps reconciler mutation on the main thread and guarantees the re-render sees the new value.
Re-arming is automatic — the re-render calls render again. Observable objects live in a dictionary
separate from EnvironmentValues.values, so they can never collide with EnvironmentKey values.
ObservableObject/@EnvironmentObject/@StateObject are untouched and both systems coexist — this
core relies on the old one itself, e.g. NavigationContext.

Verification

Each piece was developed in isolation and built with
swift build --swift-sdk swift-6.3.3-RELEASE_android; the combined branch was then built again to
confirm they compose — Build complete!, no new warnings. The Observation change was
additionally checked against a consumer-shaped file mixing @Observable, @Environment(Type.self),
@Environment(\.keyPath), @EnvironmentObject and @State, to prove no overload ambiguity was
introduced; that file was removed before committing.

None of this has run on a device or emulator. It is verified by construction and by compilation
only, which is the main risk in this PR — particularly for Observation, where the wiring is
analogous to the working objectWillChange path but unproven empirically.

Known limitations

Each is documented in the source rather than left to be discovered:

  • Observation: no @Bindable; @Environment(Store.self) var store: Store? (optional form)
    unsupported — a missing injection traps rather than yielding nil. Properties must be read during
    body evaluation to be tracked (same as SwiftUI).
  • TabView: unselected tabs are unmounted, so their @State resets, where real SwiftUI keeps tab
    identity alive. No TabViewStyle, paging, page indicator or .badge.
  • Sheet: no animation, detents or drag-to-dismiss, and no @Environment(\.dismiss) — content
    dismisses itself through the binding. Visually closer to fullScreenCover than an iOS card. If app
    code sets isPresented = false directly the sheet unmounts but onDismiss does not fire.
  • NavigationStack: NavigationStack(path:) and value-based navigationDestination(for:) are not
    implemented — NavigationContext.path holds type-erased views, not a Hashable data path.
  • Lazy stacks: spacing and pinnedViews are accepted for source compatibility and ignored,
    matching how the existing VStack/HStack Android rendering already drops spacing.

Note on an existing bug (not touched)

VStack/HStack both use _alignment.vertical.gravity, which makes VStack(alignment: .leading)
centre its children. The new lazy stacks map alignment to the correct cross axis instead, so they
and their eager counterparts currently disagree. Left alone deliberately — worth a separate fix.

@colemancda
colemancda merged commit 8ba4c15 into master Jul 21, 2026
6 checks passed
@colemancda
colemancda deleted the feature/swiftui-containers branch July 21, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant