Skip to content

Commit 8ba4c15

Browse files
authored
Merge pull request #4 from PureSwift/feature/swiftui-containers
Add NavigationStack, TabView, sheet, refreshable, lazy stacks and Observation support
2 parents ff646f4 + 471dc0f commit 8ba4c15

14 files changed

Lines changed: 1019 additions & 20 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// AndroidNavigationStack.swift
3+
// AndroidSwiftUI
4+
//
5+
// Created by Alsey Coleman Miller on 7/21/26.
6+
//
7+
8+
import AndroidKit
9+
10+
extension NavigationStack: AndroidPrimitive {
11+
12+
var renderedBody: AnyView {
13+
let proxy = _NavigationStackProxy(self)
14+
return AnyView(AndroidNavigationContainer(context: proxy.context, currentView: proxy.currentView))
15+
}
16+
}

‎Sources/AndroidSwiftUI/AndroidNavigationView.swift‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,27 @@ import AndroidKit
1010
extension NavigationView: AndroidPrimitive {
1111

1212
var renderedBody: AnyView {
13-
AnyView(AndroidNavigationContainer(proxy: _NavigationViewProxy(self)))
13+
let proxy = _NavigationViewProxy(self)
14+
return AnyView(AndroidNavigationContainer(context: proxy.context, currentView: proxy.currentView))
1415
}
1516
}
1617

17-
/// Native container for `NavigationView`. Hosts the currently visible screen (root content or the
18-
/// top of the navigation stack) as a mounted child, and intercepts the system back button to pop.
19-
struct AndroidNavigationContainer<Content: View> {
18+
/// Native container for `NavigationView` and `NavigationStack`. Hosts the currently visible screen
19+
/// (root content or the top of the navigation stack) as a mounted child, and intercepts the system
20+
/// back button to pop.
21+
struct AndroidNavigationContainer {
2022

21-
let proxy: _NavigationViewProxy<Content>
23+
/// The navigation state shared with the `NavigationLink`s of the hosted screen.
24+
let context: NavigationContext
25+
26+
/// The screen to display, provided by the container's proxy.
27+
let currentView: AnyView
2228
}
2329

2430
extension AndroidNavigationContainer: ParentView {
2531

2632
var children: [AnyView] {
27-
[proxy.currentView]
33+
[currentView]
2834
}
2935
}
3036

@@ -33,9 +39,9 @@ extension AndroidNavigationContainer: AndroidViewRepresentable {
3339
typealias Coordinator = Void
3440

3541
func makeAndroidView(context: Self.Context) -> BackHandlerView {
36-
let path = proxy.context
42+
let navigationContext = self.context
3743
let view = BackHandlerView(context.androidContext) {
38-
path.pop()
44+
navigationContext.pop()
3945
}
4046
updateView(view)
4147
return view
@@ -49,6 +55,6 @@ extension AndroidNavigationContainer: AndroidViewRepresentable {
4955
private extension AndroidNavigationContainer {
5056

5157
func updateView(_ view: BackHandlerView) {
52-
view.setBackHandlerEnabled(!proxy.context.path.isEmpty)
58+
view.setBackHandlerEnabled(!context.path.isEmpty)
5359
}
5460
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// AndroidSheet.swift
3+
// AndroidSwiftUI
4+
//
5+
// Created by Alsey Coleman Miller on 7/21/26.
6+
//
7+
8+
import AndroidKit
9+
10+
extension _SheetPresenter: AndroidPrimitive {
11+
12+
var renderedBody: AnyView {
13+
AnyView(AndroidSheetContainer(presenter: self))
14+
}
15+
}
16+
17+
/// Native container for `.sheet(isPresented:onDismiss:content:)`.
18+
///
19+
/// The sheet is rendered as an overlay rather than an Android `Dialog`: the fiber renderer mounts
20+
/// children by adding them to their parent `ViewGroup`, so the presented content has to live in the
21+
/// same view hierarchy as the presenting content. `BackHandlerView` is a `FrameLayout`, so the sheet
22+
/// content is stacked on top of the host content and the system back button is intercepted while the
23+
/// sheet is presented.
24+
struct AndroidSheetContainer<Content: View> {
25+
26+
let presenter: _SheetPresenter<Content>
27+
}
28+
29+
extension AndroidSheetContainer: ParentView {
30+
31+
var children: [AnyView] {
32+
var views = [AnyView(presenter.content)]
33+
if presenter.isSheetPresented {
34+
views.append(AnyView(AndroidSheetOverlay(content: presenter.sheetContent())))
35+
}
36+
return views
37+
}
38+
}
39+
40+
extension AndroidSheetContainer: AndroidViewRepresentable {
41+
42+
typealias Coordinator = Void
43+
44+
func makeAndroidView(context: Self.Context) -> BackHandlerView {
45+
let presenter = self.presenter
46+
let view = BackHandlerView(context.androidContext) {
47+
presenter.dismiss()
48+
}
49+
updateView(view)
50+
return view
51+
}
52+
53+
func updateAndroidView(_ view: BackHandlerView, context: Self.Context) {
54+
updateView(view)
55+
}
56+
}
57+
58+
private extension AndroidSheetContainer {
59+
60+
func updateView(_ view: BackHandlerView) {
61+
// only intercept the back button while the sheet is on screen
62+
view.setBackHandlerEnabled(presenter.isSheetPresented)
63+
}
64+
}
65+
66+
/// Opaque, full size container for the presented sheet content, stacked above the presenting content.
67+
struct AndroidSheetOverlay {
68+
69+
let content: AnyView
70+
}
71+
72+
extension AndroidSheetOverlay: ParentView {
73+
74+
var children: [AnyView] {
75+
[content]
76+
}
77+
}
78+
79+
extension AndroidSheetOverlay: AndroidViewRepresentable {
80+
81+
typealias Coordinator = Void
82+
83+
func makeAndroidView(context: Self.Context) -> AndroidWidget.FrameLayout {
84+
let androidContext = context.androidContext
85+
let view = AndroidWidget.FrameLayout(androidContext)
86+
let matchParent = try! JavaClass<ViewGroup.LayoutParams>().MATCH_PARENT
87+
view.setLayoutParams(ViewGroup.LayoutParams(matchParent, matchParent))
88+
view.setBackgroundColor(Self.backgroundColor(androidContext))
89+
// swallow touches so the presenting content underneath is not interactive
90+
view.setClickable(true)
91+
view.setFocusable(true)
92+
// draw above the presenting content
93+
view.setElevation(16)
94+
view.bringToFront()
95+
return view
96+
}
97+
98+
func updateAndroidView(_ view: AndroidWidget.FrameLayout, context: Self.Context) {
99+
view.bringToFront()
100+
}
101+
}
102+
103+
private extension AndroidSheetOverlay {
104+
105+
/// The theme's window background, so the sheet is opaque in both light and dark themes.
106+
static func backgroundColor(_ context: AndroidContent.Context) -> Int32 {
107+
let opaqueWhite = Int32(bitPattern: 0xFF_FF_FF_FF)
108+
guard let theme = context.getTheme() else {
109+
return opaqueWhite
110+
}
111+
let value = AndroidUtil.TypedValue()
112+
let attribute = try! JavaClass<AndroidR.R.attr>().colorBackground
113+
guard theme.resolveAttribute(attribute, value, true) else {
114+
return opaqueWhite
115+
}
116+
return value.data
117+
}
118+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//
2+
// AndroidTabView.swift
3+
// AndroidSwiftUI
4+
//
5+
// Created by Alsey Coleman Miller on 7/21/26.
6+
//
7+
8+
import AndroidKit
9+
10+
extension TabView: AndroidPrimitive {
11+
12+
var renderedBody: AnyView {
13+
AnyView(AndroidTabViewContainer(proxy: _TabViewProxy(self)))
14+
}
15+
}
16+
17+
/// Native container for `TabView`. A vertical `LinearLayout` that hosts the selected tab's content
18+
/// above a horizontal row of tab bar buttons.
19+
struct AndroidTabViewContainer<SelectionValue: Hashable, Content: View> {
20+
21+
let proxy: _TabViewProxy<SelectionValue, Content>
22+
}
23+
24+
extension AndroidTabViewContainer: ParentView {
25+
26+
var children: [AnyView] {
27+
let items = proxy.items
28+
let selectedIndex = proxy.selectedIndex
29+
// only the selected tab's content is mounted, hidden tabs are never rendered
30+
let content = AndroidTabContent(content: proxy.selectedContent)
31+
let bar = AndroidTabBar(
32+
labels: items.enumerated().map { index, item in
33+
item.label ?? AnyView(Text("Tab \(index + 1)"))
34+
},
35+
selectedIndex: selectedIndex,
36+
select: { proxy.select($0) }
37+
)
38+
return [AnyView(content), AnyView(bar)]
39+
}
40+
}
41+
42+
extension AndroidTabViewContainer: AndroidViewRepresentable {
43+
44+
typealias Coordinator = Void
45+
46+
func makeAndroidView(context: Self.Context) -> AndroidWidget.LinearLayout {
47+
let view = AndroidWidget.LinearLayout(context.androidContext)
48+
view.orientation = .vertical
49+
view.setLayoutParams(ViewGroup.LayoutParams(.matchParent, .matchParent))
50+
return view
51+
}
52+
53+
func updateAndroidView(_ view: AndroidWidget.LinearLayout, context: Self.Context) {
54+
55+
}
56+
}
57+
58+
// MARK: - Content
59+
60+
/// Hosts the selected tab's content, filling the space left over by the tab bar.
61+
///
62+
/// Deliberately non-generic so that switching tabs updates this container in place (instead of
63+
/// remounting it), which keeps the content above the tab bar in the parent `LinearLayout`.
64+
struct AndroidTabContent {
65+
66+
let content: AnyView
67+
}
68+
69+
extension AndroidTabContent: ParentView {
70+
71+
var children: [AnyView] {
72+
[content]
73+
}
74+
}
75+
76+
extension AndroidTabContent: AndroidViewRepresentable {
77+
78+
typealias Coordinator = Void
79+
80+
func makeAndroidView(context: Self.Context) -> AndroidWidget.LinearLayout {
81+
let view = AndroidWidget.LinearLayout(context.androidContext)
82+
view.orientation = .vertical
83+
// fill the remaining vertical space
84+
view.setLayoutParams(LinearLayout.LayoutParams(.matchParent, 0, 1).as(ViewGroup.LayoutParams.self))
85+
return view
86+
}
87+
88+
func updateAndroidView(_ view: AndroidWidget.LinearLayout, context: Self.Context) {
89+
90+
}
91+
}
92+
93+
// MARK: - Tab Bar
94+
95+
/// The row of tab bar buttons displayed at the bottom of a `TabView`.
96+
struct AndroidTabBar {
97+
98+
let labels: [AnyView]
99+
100+
let selectedIndex: Int
101+
102+
let select: (Int) -> ()
103+
}
104+
105+
extension AndroidTabBar: ParentView {
106+
107+
var children: [AnyView] {
108+
labels.enumerated().map { index, label in
109+
AnyView(AndroidTabBarButton(
110+
label: label,
111+
isSelected: index == selectedIndex,
112+
action: { select(index) }
113+
))
114+
}
115+
}
116+
}
117+
118+
extension AndroidTabBar: AndroidViewRepresentable {
119+
120+
typealias Coordinator = Void
121+
122+
func makeAndroidView(context: Self.Context) -> AndroidWidget.LinearLayout {
123+
let view = AndroidWidget.LinearLayout(context.androidContext)
124+
view.orientation = .horizontal
125+
view.gravity = .center
126+
view.setLayoutParams(LinearLayout.LayoutParams(.matchParent, .wrapContent, 0).as(ViewGroup.LayoutParams.self))
127+
return view
128+
}
129+
130+
func updateAndroidView(_ view: AndroidWidget.LinearLayout, context: Self.Context) {
131+
132+
}
133+
}
134+
135+
/// A single tab bar button, hosting its `tabItem(_:)` label as a mounted child.
136+
struct AndroidTabBarButton {
137+
138+
let label: AnyView
139+
140+
let isSelected: Bool
141+
142+
let action: () -> ()
143+
}
144+
145+
extension AndroidTabBarButton: ParentView {
146+
147+
var children: [AnyView] {
148+
[label]
149+
}
150+
}
151+
152+
extension AndroidTabBarButton: AndroidViewRepresentable {
153+
154+
typealias Coordinator = Void
155+
156+
func makeAndroidView(context: Self.Context) -> AndroidWidget.LinearLayout {
157+
let view = AndroidWidget.LinearLayout(context.androidContext)
158+
view.orientation = .vertical
159+
view.gravity = .center
160+
// every tab occupies an equal share of the tab bar
161+
view.setLayoutParams(LinearLayout.LayoutParams(0, .wrapContent, 1).as(ViewGroup.LayoutParams.self))
162+
view.setClickable(true)
163+
updateView(view)
164+
return view
165+
}
166+
167+
func updateAndroidView(_ view: AndroidWidget.LinearLayout, context: Self.Context) {
168+
updateView(view)
169+
}
170+
}
171+
172+
private extension AndroidTabBarButton {
173+
174+
func updateView(_ view: AndroidWidget.LinearLayout) {
175+
// the listener captures the current selection, so it has to be replaced on every update
176+
let listener = ViewOnClickListener(action: action)
177+
view.setOnClickListener(listener.as(AndroidView.View.OnClickListener.self))
178+
view.setAlpha(isSelected ? 1.0 : 0.5)
179+
}
180+
}
181+
182+
// MARK: - Layout Params
183+
184+
internal extension Int32 {
185+
186+
static var matchParent: Int32 {
187+
try! JavaClass<ViewGroup.LayoutParams>().MATCH_PARENT
188+
}
189+
190+
static var wrapContent: Int32 {
191+
try! JavaClass<ViewGroup.LayoutParams>().WRAP_CONTENT
192+
}
193+
}

0 commit comments

Comments
 (0)