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
5 changes: 5 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}

Expand Down
39 changes: 38 additions & 1 deletion AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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)
Expand All @@ -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
)
}
Expand Down Expand Up @@ -180,6 +192,31 @@ public extension View {
}
}

/// Attaches a search field to the enclosing navigation stack, bound to `text`.
public struct _SearchableView<Content: View>: View {
internal let text: Binding<String>
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<String>, prompt: String? = nil) -> _SearchableView<Self> {
_SearchableView(text: text, prompt: prompt, content: self)
}
}

/// Registers a value-type destination builder with the enclosing stack.
public struct _NavigationDestinationView<Content: View, D: Hashable>: View {
internal let build: (D) -> any View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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 @@ -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())),
Expand Down
34 changes: 34 additions & 0 deletions Demo/App.swiftpm/Sources/SearchablePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
42 changes: 42 additions & 0 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1243,13 +1243,55 @@ 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) }
}
}
}
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
Expand Down
Loading