diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift index 5ee13a1..82d895b 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift @@ -112,6 +112,11 @@ public protocol _ResolutionEffectView { public final class TitleSink { public var title: String? public var detents: [PresentationDetent] = [] + /// A `searchable` declared inside the screen: current text, the string + /// callback its field pushes to, and an optional placeholder prompt. + public var searchText: String? + public var searchCallbackID: Int64? + public var searchPrompt: String? public init() {} } diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift index 9cefc6a..499e117 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift @@ -76,6 +76,7 @@ extension NavigationStack: PrimitiveView { // captures its navigationTitle var screens: [RenderNode] = [] var titles: [PropValue] = [] + var searches: [PropValue] = [] func resolveScreen(_ view: any View, index: Int, canDismiss: Bool) { var childContext = context.descending("screen\(index)") @@ -87,6 +88,17 @@ extension NavigationStack: PrimitiveView { childContext.titleSink = sink screens.append(Evaluator.resolve(view, childContext)) titles.append(.string(sink.title ?? "")) + // Each screen carries its search field descriptor, or an empty array + // when it declared no `searchable`. + if let id = sink.searchCallbackID { + searches.append(.array([ + .string(sink.searchText ?? ""), + .int(Int(id)), + .string(sink.searchPrompt ?? ""), + ])) + } else { + searches.append(.array([])) + } } resolveScreen(root, index: 0, canDismiss: false) @@ -99,7 +111,7 @@ extension NavigationStack: PrimitiveView { return RenderNode( type: "NavStack", id: context.path, - props: ["titles": .array(titles), "onPop": .int(Int(popID))], + props: ["titles": .array(titles), "searches": .array(searches), "onPop": .int(Int(popID))], children: screens ) } @@ -180,6 +192,31 @@ public extension View { } } +/// Attaches a search field to the enclosing navigation stack, bound to `text`. +public struct _SearchableView: View { + internal let text: Binding + internal let prompt: String? + internal let content: Content + public typealias Body = Never +} + +extension _SearchableView: _ResolutionEffectView { + public func _applyEffect(_ context: inout ResolveContext) -> any View { + let binding = text + let id = context.callbacks.register(.string { binding.wrappedValue = $0 }) + context.titleSink?.searchText = text.wrappedValue + context.titleSink?.searchCallbackID = id + context.titleSink?.searchPrompt = prompt + return content + } +} + +public extension View { + func searchable(text: Binding, prompt: String? = nil) -> _SearchableView { + _SearchableView(text: text, prompt: prompt, content: self) + } +} + /// Registers a value-type destination builder with the enclosing stack. public struct _NavigationDestinationView: View { internal let build: (D) -> any View diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift index 4d84483..2cb9d3f 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift @@ -151,6 +151,38 @@ struct NavigationTests { #expect(node.props["hasConfirmationDialog"] == nil) // dismissed } + @Test("searchable surfaces a per-screen field whose input drives its binding") + func searchable() { + struct Screen: View { + @State var query = "" + var body: some View { + NavigationStack { + Text("Results for \(query)") + .searchable(text: $query, prompt: "Find") + } + } + } + let host = ViewHost(Screen()) + let node = host.evaluate() + #expect(node.type == "NavStack") + guard case .array(let searches)? = node.props["searches"], + case .array(let entry) = searches[0], entry.count == 3 else { + Issue.record("expected a search descriptor for the root screen"); return + } + #expect(entry[0] == .string("")) // current text + #expect(entry[2] == .string("Find")) // prompt + guard case .int(let id) = entry[1] else { + Issue.record("missing search callback id"); return + } + host.callbacks.invokeString(Int64(id), "abc") + let updated = host.evaluate() + guard case .array(let searches2)? = updated.props["searches"], + case .array(let entry2) = searches2[0] else { + Issue.record("missing search descriptor after input"); return + } + #expect(entry2[0] == .string("abc")) // binding round-tripped + } + @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 61e1a8b..56fbbf0 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -50,6 +50,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())), CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())), CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())), + CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())), 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/SearchablePlaygrounds.swift b/Demo/App.swiftpm/Sources/SearchablePlaygrounds.swift new file mode 100644 index 0000000..96044e7 --- /dev/null +++ b/Demo/App.swiftpm/Sources/SearchablePlaygrounds.swift @@ -0,0 +1,34 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct SearchablePlayground: View { + @State private var query = "" + + private let fruits = [ + "Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape", + "Lemon", "Lime", "Mango", "Orange", "Peach", "Pear", "Plum", + ] + + private var filtered: [String] { + query.isEmpty ? fruits : fruits.filter { $0.lowercased().contains(query.lowercased()) } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if filtered.isEmpty { + Text("No matches").foregroundColor(.gray) + } + ForEach(filtered, id: \.self) { fruit in + Text(fruit) + } + } + .padding() + } + .searchable(text: $query, prompt: "Search fruit") + .navigationTitle("Searchable") + } +} 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 8528dd3..c2ab1b6 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -1243,6 +1243,7 @@ private fun RenderNavStack(node: ViewNode) { label = "nav", ) { index -> LayoutColumn(modifier = Modifier.padding(padding)) { + node.searchField(index)?.let { RenderSearchField(it, index) } node.children.getOrNull(index)?.let { Render(it) } } } @@ -1250,6 +1251,47 @@ private fun RenderNavStack(node: ViewNode) { RenderSheetsAndAlerts(node.children.getOrNull(topIndex)) } +private class SearchFieldSpec(val text: String, val callbackId: Long, val prompt: String) + +// A screen's `searchable` descriptor: [text, callbackId, prompt], or an empty +// array when that screen declared none. +private fun ViewNode.searchField(index: Int): SearchFieldSpec? { + val searches = props["searches"] as? kotlinx.serialization.json.JsonArray ?: return null + val entry = searches.getOrNull(index) as? kotlinx.serialization.json.JsonArray ?: return null + if (entry.size < 3) return null + val text = (entry[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + val id = (entry[1] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return null + val prompt = (entry[2] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + return SearchFieldSpec(text, id, prompt) +} + +// Same uncontrolled-with-reconciliation scheme as RenderTextField, keyed by the +// stable screen index (the callback id churns each evaluation, so it can't key +// the remembered cursor state). +@Composable +private fun RenderSearchField(spec: SearchFieldSpec, index: Int) { + var local by remember(index) { mutableStateOf(TextFieldValue(spec.text)) } + var lastSent by remember(index) { mutableStateOf(spec.text) } + if (spec.text != lastSent) { + local = TextFieldValue(spec.text, selection = androidx.compose.ui.text.TextRange(spec.text.length)) + lastSent = spec.text + } + OutlinedTextField( + value = local, + onValueChange = { v -> + local = v + if (v.text != lastSent) { + lastSent = v.text + SwiftBridge.sink.invokeString(spec.callbackId, v.text) + } + }, + placeholder = { Text(spec.prompt.ifEmpty { "Search" }) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) +} + @Composable private fun RenderTabView(node: ViewNode) { val selection = node.long("selection")?.toInt() ?: 0