diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Toolbar.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Toolbar.swift new file mode 100644 index 0000000..1a50657 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Toolbar.swift @@ -0,0 +1,83 @@ +// +// Toolbar.swift +// AndroidSwiftUICore +// +// Toolbar items are views, not data, so — unlike `navigationTitle` — they can't +// ride to the navigation chrome through the title sink. They travel as hidden +// children of the screen node instead (the mechanism sheets and alerts use), +// each tagged with its placement, and the interpreter lifts them into the bar. +// + +/// Where a toolbar item sits in the navigation chrome. +public enum ToolbarItemPlacement: String, Sendable { + case automatic + case navigationBarLeading + case navigationBarTrailing + /// Replaces the screen's title. + case principal + case bottomBar +} + +/// A single positioned toolbar entry. +public struct ToolbarItem: View { + + internal let placement: ToolbarItemPlacement + internal let content: Content + + public init( + placement: ToolbarItemPlacement = .automatic, + @ViewBuilder content: () -> Content + ) { + self.placement = placement + self.content = content() + } + + public typealias Body = Never +} + +extension ToolbarItem: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode( + type: "ToolbarItem", + id: context.path, + props: ["placement": .string(placement.rawValue)], + children: Evaluator.resolveChildren(content, context.descending("content")) + ) + } +} + +public struct _ToolbarView: View { + internal let content: Content + internal let items: Items + public typealias Body = Never +} + +extension _ToolbarView: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + var node = Evaluator.resolve(content, context.descending("content")) + let resolved = Evaluator.resolveChildren(items, context.descending("toolbar")) + for item in resolved { + if item.type == "ToolbarItem" { + node.children.append(item) + } else { + // a bare view in the builder is an automatically-placed item + node.children.append(RenderNode( + type: "ToolbarItem", + id: item.id + "/item", + props: ["placement": .string(ToolbarItemPlacement.automatic.rawValue)], + children: [item] + )) + } + } + node.props["hasToolbar"] = .bool(true) + return node + } +} + +public extension View { + func toolbar(@ViewBuilder content: () -> Items) -> _ToolbarView { + _ToolbarView(content: self, items: content()) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift index 2cb9d3f..d9dc464 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift @@ -183,6 +183,49 @@ struct NavigationTests { #expect(entry2[0] == .string("abc")) // binding round-tripped } + @Test("toolbar rides as placed hidden children whose buttons stay live") + func toolbar() { + struct Screen: View { + @State var saved = 0 + var body: some View { + Text("Body") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { saved += 1 } + } + ToolbarItem(placement: .navigationBarLeading) { + Text("Leading") + } + // a bare view is placed automatically + Text("Bare") + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["hasToolbar"] == .bool(true)) + let items = node.children.filter { $0.type == "ToolbarItem" } + #expect(items.count == 3) + let placements = items.compactMap { item -> String? in + guard case .string(let p)? = item.props["placement"] else { return nil } + return p + } + #expect(placements == ["navigationBarTrailing", "navigationBarLeading", "automatic"]) + + // the trailing item's button still dispatches into the screen's state + guard let tap = findOnTap(items[0]) else { + Issue.record("toolbar button lost its callback"); return + } + host.callbacks.invokeVoid(tap) + node = host.evaluate() + let label = node.children + .filter { $0.type == "ToolbarItem" } + .compactMap { firstTextString($0) } + .first + #expect(label == "Save") // still rendered after the state change + #expect(host.callbacks.callback(for: tap) != nil) + } + @Test("TabView emits tabs with their item labels and selection") func tabs() { struct Screen: View { diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 783361f..fe81102 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -53,6 +53,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "gesture", title: "Gestures", screen: AnyCatalogScreen(GesturePlayground())), CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())), CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())), + CatalogEntry(id: "toolbar", title: "Toolbar", screen: AnyCatalogScreen(ToolbarPlayground())), CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())), CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())), CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())), diff --git a/Demo/App.swiftpm/Sources/ToolbarPlaygrounds.swift b/Demo/App.swiftpm/Sources/ToolbarPlaygrounds.swift new file mode 100644 index 0000000..11ba4f3 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ToolbarPlaygrounds.swift @@ -0,0 +1,41 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct ToolbarPlayground: View { + + @State private var score = 0 + @State private var principalTitle = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Score: \(score)") + Text("Save adds 1, Flag adds 10, the bottom action adds 100.") + Button(principalTitle ? "Use the plain title" : "Use a principal title") { + principalTitle.toggle() + } + } + .padding() + } + .navigationTitle("Toolbar") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Flag") { score += 10 } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { score += 1 } + } + if principalTitle { + ToolbarItem(placement: .principal) { + Text("Principal") + } + } + ToolbarItem(placement: .bottomBar) { + Button("Bottom action") { score += 100 } + } + } + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 2c69b5c..366f378 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -57,6 +57,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -419,7 +420,7 @@ private fun RenderResolved(node: ViewNode) { // Sheets and alerts ride as hidden children; the presentation layer shows // them, so the normal child loop skips them. private fun ViewNode.isPresentation(): Boolean = - type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" + type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem" @Composable private fun RenderChildren(node: ViewNode) { @@ -1308,22 +1309,49 @@ private fun RenderNavStack(node: ViewNode) { val depth = node.children.size // cache rendered screens by index so a popped screen can animate out val topIndex = depth - 1 + val screen = node.children.getOrNull(topIndex) + val toolbar = screen?.children?.filter { it.type == "ToolbarItem" }.orEmpty() + fun placed(vararg names: String) = toolbar.filter { (it.string("placement") ?: "automatic") in names } + val leading = placed("navigationBarLeading") + // an unplaced item goes to the trailing side, as it does on iOS + val trailing = placed("navigationBarTrailing", "automatic") + val principal = placed("principal") + val bottom = placed("bottomBar") + Scaffold( topBar = { val title = titles.getOrNull(topIndex).orEmpty() - if (title.isNotEmpty() || depth > 1) { + if (title.isNotEmpty() || depth > 1 || toolbar.isNotEmpty()) { TopAppBar( - title = { Text(title) }, + title = { + // a principal item replaces the title outright + if (principal.isNotEmpty()) principal.forEach { RenderToolbarItem(it) } + else Text(title) + }, navigationIcon = { - if (depth > 1) { - IconButton(onClick = { onPop?.let { SwiftBridge.sink.invokeVoid(it) } }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Row(verticalAlignment = Alignment.CenterVertically) { + if (depth > 1) { + IconButton(onClick = { onPop?.let { SwiftBridge.sink.invokeVoid(it) } }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } } + leading.forEach { RenderToolbarItem(it) } } }, + actions = { trailing.forEach { RenderToolbarItem(it) } }, ) } }, + bottomBar = { + if (bottom.isNotEmpty()) { + BottomAppBar { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + ) { bottom.forEach { RenderToolbarItem(it) } } + } + } + }, ) { padding -> AnimatedContent( targetState = topIndex, @@ -1420,6 +1448,12 @@ private fun RenderTabView(node: ViewNode) { } } +// A toolbar item's content, lifted out of the screen body into the bar. +@Composable +private fun RenderToolbarItem(item: ViewNode) { + item.children.forEach { RenderChild(it) } +} + // Sheets and alerts ride as hidden children of a screen node; present them // when the screen carries the corresponding flag. @OptIn(ExperimentalMaterial3Api::class)