Skip to content

Commit ba13d06

Browse files
authored
Merge pull request #51 from PureSwift/feature/toolbar
Add toolbar with placed items
2 parents 775ecc9 + 2d5f4fe commit ba13d06

5 files changed

Lines changed: 208 additions & 6 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// Toolbar.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Toolbar items are views, not data, so — unlike `navigationTitle` — they can't
6+
// ride to the navigation chrome through the title sink. They travel as hidden
7+
// children of the screen node instead (the mechanism sheets and alerts use),
8+
// each tagged with its placement, and the interpreter lifts them into the bar.
9+
//
10+
11+
/// Where a toolbar item sits in the navigation chrome.
12+
public enum ToolbarItemPlacement: String, Sendable {
13+
case automatic
14+
case navigationBarLeading
15+
case navigationBarTrailing
16+
/// Replaces the screen's title.
17+
case principal
18+
case bottomBar
19+
}
20+
21+
/// A single positioned toolbar entry.
22+
public struct ToolbarItem<Content: View>: View {
23+
24+
internal let placement: ToolbarItemPlacement
25+
internal let content: Content
26+
27+
public init(
28+
placement: ToolbarItemPlacement = .automatic,
29+
@ViewBuilder content: () -> Content
30+
) {
31+
self.placement = placement
32+
self.content = content()
33+
}
34+
35+
public typealias Body = Never
36+
}
37+
38+
extension ToolbarItem: PrimitiveView {
39+
40+
public func _render(in context: ResolveContext) -> RenderNode {
41+
RenderNode(
42+
type: "ToolbarItem",
43+
id: context.path,
44+
props: ["placement": .string(placement.rawValue)],
45+
children: Evaluator.resolveChildren(content, context.descending("content"))
46+
)
47+
}
48+
}
49+
50+
public struct _ToolbarView<Content: View, Items: View>: View {
51+
internal let content: Content
52+
internal let items: Items
53+
public typealias Body = Never
54+
}
55+
56+
extension _ToolbarView: PrimitiveView {
57+
58+
public func _render(in context: ResolveContext) -> RenderNode {
59+
var node = Evaluator.resolve(content, context.descending("content"))
60+
let resolved = Evaluator.resolveChildren(items, context.descending("toolbar"))
61+
for item in resolved {
62+
if item.type == "ToolbarItem" {
63+
node.children.append(item)
64+
} else {
65+
// a bare view in the builder is an automatically-placed item
66+
node.children.append(RenderNode(
67+
type: "ToolbarItem",
68+
id: item.id + "/item",
69+
props: ["placement": .string(ToolbarItemPlacement.automatic.rawValue)],
70+
children: [item]
71+
))
72+
}
73+
}
74+
node.props["hasToolbar"] = .bool(true)
75+
return node
76+
}
77+
}
78+
79+
public extension View {
80+
func toolbar<Items: View>(@ViewBuilder content: () -> Items) -> _ToolbarView<Self, Items> {
81+
_ToolbarView(content: self, items: content())
82+
}
83+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,49 @@ struct NavigationTests {
183183
#expect(entry2[0] == .string("abc")) // binding round-tripped
184184
}
185185

186+
@Test("toolbar rides as placed hidden children whose buttons stay live")
187+
func toolbar() {
188+
struct Screen: View {
189+
@State var saved = 0
190+
var body: some View {
191+
Text("Body")
192+
.toolbar {
193+
ToolbarItem(placement: .navigationBarTrailing) {
194+
Button("Save") { saved += 1 }
195+
}
196+
ToolbarItem(placement: .navigationBarLeading) {
197+
Text("Leading")
198+
}
199+
// a bare view is placed automatically
200+
Text("Bare")
201+
}
202+
}
203+
}
204+
let host = ViewHost(Screen())
205+
var node = host.evaluate()
206+
#expect(node.props["hasToolbar"] == .bool(true))
207+
let items = node.children.filter { $0.type == "ToolbarItem" }
208+
#expect(items.count == 3)
209+
let placements = items.compactMap { item -> String? in
210+
guard case .string(let p)? = item.props["placement"] else { return nil }
211+
return p
212+
}
213+
#expect(placements == ["navigationBarTrailing", "navigationBarLeading", "automatic"])
214+
215+
// the trailing item's button still dispatches into the screen's state
216+
guard let tap = findOnTap(items[0]) else {
217+
Issue.record("toolbar button lost its callback"); return
218+
}
219+
host.callbacks.invokeVoid(tap)
220+
node = host.evaluate()
221+
let label = node.children
222+
.filter { $0.type == "ToolbarItem" }
223+
.compactMap { firstTextString($0) }
224+
.first
225+
#expect(label == "Save") // still rendered after the state change
226+
#expect(host.callbacks.callback(for: tap) != nil)
227+
}
228+
186229
@Test("TabView emits tabs with their item labels and selection")
187230
func tabs() {
188231
struct Screen: View {

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ struct CatalogEntry: Identifiable {
5353
CatalogEntry(id: "gesture", title: "Gestures", screen: AnyCatalogScreen(GesturePlayground())),
5454
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
5555
CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())),
56+
CatalogEntry(id: "toolbar", title: "Toolbar", screen: AnyCatalogScreen(ToolbarPlayground())),
5657
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
5758
CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())),
5859
CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())),
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct ToolbarPlayground: View {
8+
9+
@State private var score = 0
10+
@State private var principalTitle = false
11+
12+
var body: some View {
13+
ScrollView {
14+
VStack(alignment: .leading, spacing: 12) {
15+
Text("Score: \(score)")
16+
Text("Save adds 1, Flag adds 10, the bottom action adds 100.")
17+
Button(principalTitle ? "Use the plain title" : "Use a principal title") {
18+
principalTitle.toggle()
19+
}
20+
}
21+
.padding()
22+
}
23+
.navigationTitle("Toolbar")
24+
.toolbar {
25+
ToolbarItem(placement: .navigationBarLeading) {
26+
Button("Flag") { score += 10 }
27+
}
28+
ToolbarItem(placement: .navigationBarTrailing) {
29+
Button("Save") { score += 1 }
30+
}
31+
if principalTitle {
32+
ToolbarItem(placement: .principal) {
33+
Text("Principal")
34+
}
35+
}
36+
ToolbarItem(placement: .bottomBar) {
37+
Button("Bottom action") { score += 100 }
38+
}
39+
}
40+
}
41+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import androidx.compose.foundation.lazy.LazyColumn
5757
import androidx.compose.foundation.lazy.grid.GridCells
5858
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
5959
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
60+
import androidx.compose.material3.BottomAppBar
6061
import androidx.compose.material3.AlertDialog
6162
import androidx.compose.material3.Button
6263
import androidx.compose.material3.ButtonDefaults
@@ -419,7 +420,7 @@ private fun RenderResolved(node: ViewNode) {
419420
// Sheets and alerts ride as hidden children; the presentation layer shows
420421
// them, so the normal child loop skips them.
421422
private fun ViewNode.isPresentation(): Boolean =
422-
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog"
423+
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"
423424

424425
@Composable
425426
private fun RenderChildren(node: ViewNode) {
@@ -1308,22 +1309,49 @@ private fun RenderNavStack(node: ViewNode) {
13081309
val depth = node.children.size
13091310
// cache rendered screens by index so a popped screen can animate out
13101311
val topIndex = depth - 1
1312+
val screen = node.children.getOrNull(topIndex)
1313+
val toolbar = screen?.children?.filter { it.type == "ToolbarItem" }.orEmpty()
1314+
fun placed(vararg names: String) = toolbar.filter { (it.string("placement") ?: "automatic") in names }
1315+
val leading = placed("navigationBarLeading")
1316+
// an unplaced item goes to the trailing side, as it does on iOS
1317+
val trailing = placed("navigationBarTrailing", "automatic")
1318+
val principal = placed("principal")
1319+
val bottom = placed("bottomBar")
1320+
13111321
Scaffold(
13121322
topBar = {
13131323
val title = titles.getOrNull(topIndex).orEmpty()
1314-
if (title.isNotEmpty() || depth > 1) {
1324+
if (title.isNotEmpty() || depth > 1 || toolbar.isNotEmpty()) {
13151325
TopAppBar(
1316-
title = { Text(title) },
1326+
title = {
1327+
// a principal item replaces the title outright
1328+
if (principal.isNotEmpty()) principal.forEach { RenderToolbarItem(it) }
1329+
else Text(title)
1330+
},
13171331
navigationIcon = {
1318-
if (depth > 1) {
1319-
IconButton(onClick = { onPop?.let { SwiftBridge.sink.invokeVoid(it) } }) {
1320-
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
1332+
Row(verticalAlignment = Alignment.CenterVertically) {
1333+
if (depth > 1) {
1334+
IconButton(onClick = { onPop?.let { SwiftBridge.sink.invokeVoid(it) } }) {
1335+
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
1336+
}
13211337
}
1338+
leading.forEach { RenderToolbarItem(it) }
13221339
}
13231340
},
1341+
actions = { trailing.forEach { RenderToolbarItem(it) } },
13241342
)
13251343
}
13261344
},
1345+
bottomBar = {
1346+
if (bottom.isNotEmpty()) {
1347+
BottomAppBar {
1348+
Row(
1349+
verticalAlignment = Alignment.CenterVertically,
1350+
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
1351+
) { bottom.forEach { RenderToolbarItem(it) } }
1352+
}
1353+
}
1354+
},
13271355
) { padding ->
13281356
AnimatedContent(
13291357
targetState = topIndex,
@@ -1420,6 +1448,12 @@ private fun RenderTabView(node: ViewNode) {
14201448
}
14211449
}
14221450

1451+
// A toolbar item's content, lifted out of the screen body into the bar.
1452+
@Composable
1453+
private fun RenderToolbarItem(item: ViewNode) {
1454+
item.children.forEach { RenderChild(it) }
1455+
}
1456+
14231457
// Sheets and alerts ride as hidden children of a screen node; present them
14241458
// when the screen carries the corresponding flag.
14251459
@OptIn(ExperimentalMaterial3Api::class)

0 commit comments

Comments
 (0)