Skip to content

Commit ebbcaae

Browse files
authored
Merge pull request #48 from PureSwift/feature/searchable
Add searchable — a navigation search field
2 parents db4169e + 1f62588 commit ebbcaae

6 files changed

Lines changed: 152 additions & 1 deletion

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ public protocol _ResolutionEffectView {
112112
public final class TitleSink {
113113
public var title: String?
114114
public var detents: [PresentationDetent] = []
115+
/// A `searchable` declared inside the screen: current text, the string
116+
/// callback its field pushes to, and an optional placeholder prompt.
117+
public var searchText: String?
118+
public var searchCallbackID: Int64?
119+
public var searchPrompt: String?
115120
public init() {}
116121
}
117122

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ extension NavigationStack: PrimitiveView {
7676
// captures its navigationTitle
7777
var screens: [RenderNode] = []
7878
var titles: [PropValue] = []
79+
var searches: [PropValue] = []
7980

8081
func resolveScreen(_ view: any View, index: Int, canDismiss: Bool) {
8182
var childContext = context.descending("screen\(index)")
@@ -87,6 +88,17 @@ extension NavigationStack: PrimitiveView {
8788
childContext.titleSink = sink
8889
screens.append(Evaluator.resolve(view, childContext))
8990
titles.append(.string(sink.title ?? ""))
91+
// Each screen carries its search field descriptor, or an empty array
92+
// when it declared no `searchable`.
93+
if let id = sink.searchCallbackID {
94+
searches.append(.array([
95+
.string(sink.searchText ?? ""),
96+
.int(Int(id)),
97+
.string(sink.searchPrompt ?? ""),
98+
]))
99+
} else {
100+
searches.append(.array([]))
101+
}
90102
}
91103

92104
resolveScreen(root, index: 0, canDismiss: false)
@@ -99,7 +111,7 @@ extension NavigationStack: PrimitiveView {
99111
return RenderNode(
100112
type: "NavStack",
101113
id: context.path,
102-
props: ["titles": .array(titles), "onPop": .int(Int(popID))],
114+
props: ["titles": .array(titles), "searches": .array(searches), "onPop": .int(Int(popID))],
103115
children: screens
104116
)
105117
}
@@ -180,6 +192,31 @@ public extension View {
180192
}
181193
}
182194

195+
/// Attaches a search field to the enclosing navigation stack, bound to `text`.
196+
public struct _SearchableView<Content: View>: View {
197+
internal let text: Binding<String>
198+
internal let prompt: String?
199+
internal let content: Content
200+
public typealias Body = Never
201+
}
202+
203+
extension _SearchableView: _ResolutionEffectView {
204+
public func _applyEffect(_ context: inout ResolveContext) -> any View {
205+
let binding = text
206+
let id = context.callbacks.register(.string { binding.wrappedValue = $0 })
207+
context.titleSink?.searchText = text.wrappedValue
208+
context.titleSink?.searchCallbackID = id
209+
context.titleSink?.searchPrompt = prompt
210+
return content
211+
}
212+
}
213+
214+
public extension View {
215+
func searchable(text: Binding<String>, prompt: String? = nil) -> _SearchableView<Self> {
216+
_SearchableView(text: text, prompt: prompt, content: self)
217+
}
218+
}
219+
183220
/// Registers a value-type destination builder with the enclosing stack.
184221
public struct _NavigationDestinationView<Content: View, D: Hashable>: View {
185222
internal let build: (D) -> any View

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,38 @@ struct NavigationTests {
151151
#expect(node.props["hasConfirmationDialog"] == nil) // dismissed
152152
}
153153

154+
@Test("searchable surfaces a per-screen field whose input drives its binding")
155+
func searchable() {
156+
struct Screen: View {
157+
@State var query = ""
158+
var body: some View {
159+
NavigationStack {
160+
Text("Results for \(query)")
161+
.searchable(text: $query, prompt: "Find")
162+
}
163+
}
164+
}
165+
let host = ViewHost(Screen())
166+
let node = host.evaluate()
167+
#expect(node.type == "NavStack")
168+
guard case .array(let searches)? = node.props["searches"],
169+
case .array(let entry) = searches[0], entry.count == 3 else {
170+
Issue.record("expected a search descriptor for the root screen"); return
171+
}
172+
#expect(entry[0] == .string("")) // current text
173+
#expect(entry[2] == .string("Find")) // prompt
174+
guard case .int(let id) = entry[1] else {
175+
Issue.record("missing search callback id"); return
176+
}
177+
host.callbacks.invokeString(Int64(id), "abc")
178+
let updated = host.evaluate()
179+
guard case .array(let searches2)? = updated.props["searches"],
180+
case .array(let entry2) = searches2[0] else {
181+
Issue.record("missing search descriptor after input"); return
182+
}
183+
#expect(entry2[0] == .string("abc")) // binding round-tripped
184+
}
185+
154186
@Test("TabView emits tabs with their item labels and selection")
155187
func tabs() {
156188
struct Screen: View {

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ struct CatalogEntry: Identifiable {
5050
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
5151
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
5252
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
53+
CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())),
5354
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
5455
CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())),
5556
CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())),
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct SearchablePlayground: View {
8+
@State private var query = ""
9+
10+
private let fruits = [
11+
"Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape",
12+
"Lemon", "Lime", "Mango", "Orange", "Peach", "Pear", "Plum",
13+
]
14+
15+
private var filtered: [String] {
16+
query.isEmpty ? fruits : fruits.filter { $0.lowercased().contains(query.lowercased()) }
17+
}
18+
19+
var body: some View {
20+
ScrollView {
21+
VStack(alignment: .leading, spacing: 12) {
22+
if filtered.isEmpty {
23+
Text("No matches").foregroundColor(.gray)
24+
}
25+
ForEach(filtered, id: \.self) { fruit in
26+
Text(fruit)
27+
}
28+
}
29+
.padding()
30+
}
31+
.searchable(text: $query, prompt: "Search fruit")
32+
.navigationTitle("Searchable")
33+
}
34+
}

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,13 +1243,55 @@ private fun RenderNavStack(node: ViewNode) {
12431243
label = "nav",
12441244
) { index ->
12451245
LayoutColumn(modifier = Modifier.padding(padding)) {
1246+
node.searchField(index)?.let { RenderSearchField(it, index) }
12461247
node.children.getOrNull(index)?.let { Render(it) }
12471248
}
12481249
}
12491250
}
12501251
RenderSheetsAndAlerts(node.children.getOrNull(topIndex))
12511252
}
12521253

1254+
private class SearchFieldSpec(val text: String, val callbackId: Long, val prompt: String)
1255+
1256+
// A screen's `searchable` descriptor: [text, callbackId, prompt], or an empty
1257+
// array when that screen declared none.
1258+
private fun ViewNode.searchField(index: Int): SearchFieldSpec? {
1259+
val searches = props["searches"] as? kotlinx.serialization.json.JsonArray ?: return null
1260+
val entry = searches.getOrNull(index) as? kotlinx.serialization.json.JsonArray ?: return null
1261+
if (entry.size < 3) return null
1262+
val text = (entry[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
1263+
val id = (entry[1] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return null
1264+
val prompt = (entry[2] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
1265+
return SearchFieldSpec(text, id, prompt)
1266+
}
1267+
1268+
// Same uncontrolled-with-reconciliation scheme as RenderTextField, keyed by the
1269+
// stable screen index (the callback id churns each evaluation, so it can't key
1270+
// the remembered cursor state).
1271+
@Composable
1272+
private fun RenderSearchField(spec: SearchFieldSpec, index: Int) {
1273+
var local by remember(index) { mutableStateOf(TextFieldValue(spec.text)) }
1274+
var lastSent by remember(index) { mutableStateOf(spec.text) }
1275+
if (spec.text != lastSent) {
1276+
local = TextFieldValue(spec.text, selection = androidx.compose.ui.text.TextRange(spec.text.length))
1277+
lastSent = spec.text
1278+
}
1279+
OutlinedTextField(
1280+
value = local,
1281+
onValueChange = { v ->
1282+
local = v
1283+
if (v.text != lastSent) {
1284+
lastSent = v.text
1285+
SwiftBridge.sink.invokeString(spec.callbackId, v.text)
1286+
}
1287+
},
1288+
placeholder = { Text(spec.prompt.ifEmpty { "Search" }) },
1289+
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1290+
singleLine = true,
1291+
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
1292+
)
1293+
}
1294+
12531295
@Composable
12541296
private fun RenderTabView(node: ViewNode) {
12551297
val selection = node.long("selection")?.toInt() ?: 0

0 commit comments

Comments
 (0)