diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/FocusState.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/FocusState.swift new file mode 100644 index 0000000..1dda1e3 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/FocusState.swift @@ -0,0 +1,115 @@ +// +// FocusState.swift +// AndroidSwiftUICore +// +// Keyboard focus as bindable state. `@FocusState` stores its value in the same +// persisted box `@State` uses, so it survives re-evaluation; `.focused(_:)` +// links a field to it, driving focus when Swift writes the value and reporting +// back when the user moves focus themselves. +// + +/// A property wrapper holding which field (if any) currently has focus. +/// +/// Use `Bool` for a single field, or an optional `Hashable` to arbitrate +/// between several: `@FocusState private var focused: Field?`. +@propertyWrapper +public struct FocusState: DynamicProperty { + + internal let box: StateBox + + public init(wrappedValue value: Value) { + self.box = StateBox(value) + } + + public var wrappedValue: Value { + get { box.value } + nonmutating set { box.value = newValue } + } + + public var projectedValue: Binding { + Binding(box: box) + } + + /// The `$`-projection `.focused(_:)` accepts. Distinct from `SwiftUI.Binding` + /// so only focus state can be bound to a field's focus. + @propertyWrapper + public struct Binding { + + internal let box: StateBox + + public var wrappedValue: Value { + get { box.value } + nonmutating set { box.value = newValue } + } + + public var projectedValue: Binding { self } + } +} + +public extension FocusState where Value == Bool { + init() { self.init(wrappedValue: false) } +} + +public extension FocusState where Value: ExpressibleByNilLiteral { + init() { self.init(wrappedValue: nil) } +} + +extension FocusState: _StatePropertyReflectable { + public var _box: AnyObject { box } +} + +// MARK: - focused + +public struct _FocusedModifier: RenderModifier, _CallbackModifier { + + /// Whether this field is the one the focus state currently names. + let isFocused: Bool + /// Called by the interpreter as the field gains (true) or loses (false) focus. + let setFocused: (Bool) -> Void + + public var _modifierNode: ModifierNode { ModifierNode(kind: "focused") } + + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.bool(setFocused)) + return ModifierNode(kind: "focused", args: [ + "isFocused": .bool(isFocused), + "onChange": .int(Int(id)), + ]) + } +} + +public extension View { + + /// Binds this field's focus to a `Bool` focus state. + func focused(_ condition: FocusState.Binding) -> ModifiedContent { + modifier(_FocusedModifier( + isFocused: condition.wrappedValue, + setFocused: { gained in + if gained { + condition.wrappedValue = true + } else if condition.wrappedValue { + condition.wrappedValue = false + } + } + )) + } + + /// Binds this field's focus to `value` within a shared focus state. + func focused( + _ binding: FocusState.Binding, + equals value: V + ) -> ModifiedContent { + modifier(_FocusedModifier( + isFocused: binding.wrappedValue == value, + setFocused: { gained in + if gained { + binding.wrappedValue = value + } else if binding.wrappedValue == value { + // only surrender focus if it is still ours — a sibling that + // just took focus must not be cleared by our blur + binding.wrappedValue = nil + } + } + )) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index e1349a6..448e197 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -48,6 +48,64 @@ struct ControlTests { #expect(node.props["text"] == .string("Coleman")) } + @Test("focused drives a field's focus and adopts focus the user gives it") + func focusState() { + struct Screen: View { + @State var name = "" + @FocusState var focused = false + var body: some View { + TextField("Name", text: $name).focused($focused) + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(isFocused(node) == false) + guard let callback = focusCallback(node) else { + Issue.record("missing focus callback"); return + } + // the user tapping the field reports focus back into the state + host.callbacks.invokeBool(callback, true) + node = host.evaluate() + #expect(isFocused(node) == true) + // and blurring clears it + host.callbacks.invokeBool(focusCallback(node) ?? callback, false) + node = host.evaluate() + #expect(isFocused(node) == false) + } + + @Test("focused(equals:) arbitrates between fields and a sibling's blur can't steal focus") + func focusStateEquals() { + enum Field: Hashable { case first, second } + struct Screen: View { + @State var a = "" + @State var b = "" + @FocusState var focus: Field? = nil + var body: some View { + VStack { + TextField("A", text: $a).focused($focus, equals: .first) + TextField("B", text: $b).focused($focus, equals: .second) + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(isFocused(node.children[0]) == false) + #expect(isFocused(node.children[1]) == false) + guard let firstCallback = focusCallback(node.children[0]), + let secondCallback = focusCallback(node.children[1]) else { + Issue.record("missing focus callbacks"); return + } + // second field takes focus + host.callbacks.invokeBool(secondCallback, true) + node = host.evaluate() + #expect(isFocused(node.children[0]) == false) + #expect(isFocused(node.children[1]) == true) + // the first field's late blur must NOT clear the second field's focus + host.callbacks.invokeBool(firstCallback, false) + node = host.evaluate() + #expect(isFocused(node.children[1]) == true) + } + @Test("Picker emits tagged children and maps the selection string back") func picker() { struct Screen: View { @@ -215,3 +273,17 @@ struct ControlTests { #expect(node.props["millis"] == .double(changed.timeIntervalSince1970 * 1000)) } } + +/// Whether a field's `focused` modifier currently claims focus. +private func isFocused(_ node: RenderNode) -> Bool? { + guard let mod = node.modifiers.first(where: { $0.kind == "focused" }), + case .bool(let value)? = mod.args["isFocused"] else { return nil } + return value +} + +/// The callback a field's `focused` modifier reports focus changes through. +private func focusCallback(_ node: RenderNode) -> Int64? { + guard let mod = node.modifiers.first(where: { $0.kind == "focused" }), + case .int(let id)? = mod.args["onChange"] else { return nil } + return Int64(id) +} diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 56fbbf0..7a31b4c 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -30,6 +30,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "toggle", title: "Toggle", screen: AnyCatalogScreen(TogglePlayground())), CatalogEntry(id: "slider", title: "Slider", screen: AnyCatalogScreen(SliderPlayground())), CatalogEntry(id: "textfield", title: "TextField", screen: AnyCatalogScreen(TextFieldPlayground())), + CatalogEntry(id: "focus", title: "FocusState", screen: AnyCatalogScreen(FocusPlayground())), CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())), CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())), CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())), diff --git a/Demo/App.swiftpm/Sources/FocusPlaygrounds.swift b/Demo/App.swiftpm/Sources/FocusPlaygrounds.swift new file mode 100644 index 0000000..de12659 --- /dev/null +++ b/Demo/App.swiftpm/Sources/FocusPlaygrounds.swift @@ -0,0 +1,42 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct FocusPlayground: View { + + private enum Field: Hashable { case name, email } + + @State private var name = "" + @State private var email = "" + @FocusState private var focus: Field? + + private var focusLabel: String { + switch focus { + case .name: return "Name" + case .email: return "Email" + case nil: return "none" + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Focused field: \(focusLabel)") + + TextField("Name", text: $name) + .focused($focus, equals: .name) + TextField("Email", text: $email) + .focused($focus, equals: .email) + + // driving focus from Swift + Button("Focus name") { focus = .name } + Button("Focus email") { focus = .email } + Button("Dismiss keyboard") { focus = nil } + } + .padding() + } + .navigationTitle("FocusState") + } +} 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 c2ab1b6..efb78dc 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -131,6 +131,10 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.foundation.shape.CircleShape @@ -434,6 +438,27 @@ private fun RenderTextField(node: ViewNode) { local = TextFieldValue(swiftText, selection = androidx.compose.ui.text.TextRange(swiftText.length)) lastSent = swiftText } + // `.focused` binding: Swift drives focus through `isFocused`, and the field + // reports back when the user moves focus themselves. + val focus = node.modifiers.firstOrNull { it.kind == "focused" } + val shouldFocus = focus?.args?.string("isFocused") == "true" + val onFocusChange = focus?.args?.long("onChange") + val requester = remember(node.id) { FocusRequester() } + val focusManager = LocalFocusManager.current + var hasFocus by remember(node.id) { mutableStateOf(false) } + + var fieldModifier = node.composeModifiers().fillMaxWidth() + if (focus != null) { + fieldModifier = fieldModifier + .focusRequester(requester) + .onFocusChanged { state -> + if (state.isFocused != hasFocus) { + hasFocus = state.isFocused + onFocusChange?.let { SwiftBridge.sink.invokeBool(it, state.isFocused) } + } + } + } + OutlinedTextField( value = local, onValueChange = { v -> @@ -446,8 +471,15 @@ private fun RenderTextField(node: ViewNode) { label = { Text(node.string("placeholder") ?: "") }, enabled = node.isEnabled(), visualTransformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None, - modifier = node.composeModifiers().fillMaxWidth(), + modifier = fieldModifier, ) + + if (focus != null) { + LaunchedEffect(shouldFocus) { + if (shouldFocus && !hasFocus) requester.requestFocus() + else if (!shouldFocus && hasFocus) focusManager.clearFocus() + } + } } // Label, then − / + edge buttons pushed to the trailing side. @@ -1123,7 +1155,7 @@ private val KNOWN_MODIFIER_KINDS = setOf( "scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled", "font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment", "tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem", - "transition", + "transition", "focused", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded