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
83 changes: 83 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Toolbar.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>: 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<Content: View, Items: View>: 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<Items: View>(@ViewBuilder content: () -> Items) -> _ToolbarView<Self, Items> {
_ToolbarView(content: self, items: content())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
41 changes: 41 additions & 0 deletions Demo/App.swiftpm/Sources/ToolbarPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}
}
46 changes: 40 additions & 6 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading