Skip to content

Commit 1f889b5

Browse files
authored
Merge pull request #49 from PureSwift/feature/focus-state
Add @focusstate and .focused
2 parents ebbcaae + e7004fb commit 1f889b5

5 files changed

Lines changed: 264 additions & 2 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//
2+
// FocusState.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Keyboard focus as bindable state. `@FocusState` stores its value in the same
6+
// persisted box `@State` uses, so it survives re-evaluation; `.focused(_:)`
7+
// links a field to it, driving focus when Swift writes the value and reporting
8+
// back when the user moves focus themselves.
9+
//
10+
11+
/// A property wrapper holding which field (if any) currently has focus.
12+
///
13+
/// Use `Bool` for a single field, or an optional `Hashable` to arbitrate
14+
/// between several: `@FocusState private var focused: Field?`.
15+
@propertyWrapper
16+
public struct FocusState<Value: Hashable>: DynamicProperty {
17+
18+
internal let box: StateBox<Value>
19+
20+
public init(wrappedValue value: Value) {
21+
self.box = StateBox(value)
22+
}
23+
24+
public var wrappedValue: Value {
25+
get { box.value }
26+
nonmutating set { box.value = newValue }
27+
}
28+
29+
public var projectedValue: Binding {
30+
Binding(box: box)
31+
}
32+
33+
/// The `$`-projection `.focused(_:)` accepts. Distinct from `SwiftUI.Binding`
34+
/// so only focus state can be bound to a field's focus.
35+
@propertyWrapper
36+
public struct Binding {
37+
38+
internal let box: StateBox<Value>
39+
40+
public var wrappedValue: Value {
41+
get { box.value }
42+
nonmutating set { box.value = newValue }
43+
}
44+
45+
public var projectedValue: Binding { self }
46+
}
47+
}
48+
49+
public extension FocusState where Value == Bool {
50+
init() { self.init(wrappedValue: false) }
51+
}
52+
53+
public extension FocusState where Value: ExpressibleByNilLiteral {
54+
init() { self.init(wrappedValue: nil) }
55+
}
56+
57+
extension FocusState: _StatePropertyReflectable {
58+
public var _box: AnyObject { box }
59+
}
60+
61+
// MARK: - focused
62+
63+
public struct _FocusedModifier: RenderModifier, _CallbackModifier {
64+
65+
/// Whether this field is the one the focus state currently names.
66+
let isFocused: Bool
67+
/// Called by the interpreter as the field gains (true) or loses (false) focus.
68+
let setFocused: (Bool) -> Void
69+
70+
public var _modifierNode: ModifierNode { ModifierNode(kind: "focused") }
71+
72+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
73+
let id = context.callbacks.register(.bool(setFocused))
74+
return ModifierNode(kind: "focused", args: [
75+
"isFocused": .bool(isFocused),
76+
"onChange": .int(Int(id)),
77+
])
78+
}
79+
}
80+
81+
public extension View {
82+
83+
/// Binds this field's focus to a `Bool` focus state.
84+
func focused(_ condition: FocusState<Bool>.Binding) -> ModifiedContent<Self, _FocusedModifier> {
85+
modifier(_FocusedModifier(
86+
isFocused: condition.wrappedValue,
87+
setFocused: { gained in
88+
if gained {
89+
condition.wrappedValue = true
90+
} else if condition.wrappedValue {
91+
condition.wrappedValue = false
92+
}
93+
}
94+
))
95+
}
96+
97+
/// Binds this field's focus to `value` within a shared focus state.
98+
func focused<V: Hashable>(
99+
_ binding: FocusState<V?>.Binding,
100+
equals value: V
101+
) -> ModifiedContent<Self, _FocusedModifier> {
102+
modifier(_FocusedModifier(
103+
isFocused: binding.wrappedValue == value,
104+
setFocused: { gained in
105+
if gained {
106+
binding.wrappedValue = value
107+
} else if binding.wrappedValue == value {
108+
// only surrender focus if it is still ours — a sibling that
109+
// just took focus must not be cleared by our blur
110+
binding.wrappedValue = nil
111+
}
112+
}
113+
))
114+
}
115+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,64 @@ struct ControlTests {
4848
#expect(node.props["text"] == .string("Coleman"))
4949
}
5050

51+
@Test("focused drives a field's focus and adopts focus the user gives it")
52+
func focusState() {
53+
struct Screen: View {
54+
@State var name = ""
55+
@FocusState var focused = false
56+
var body: some View {
57+
TextField("Name", text: $name).focused($focused)
58+
}
59+
}
60+
let host = ViewHost(Screen())
61+
var node = host.evaluate()
62+
#expect(isFocused(node) == false)
63+
guard let callback = focusCallback(node) else {
64+
Issue.record("missing focus callback"); return
65+
}
66+
// the user tapping the field reports focus back into the state
67+
host.callbacks.invokeBool(callback, true)
68+
node = host.evaluate()
69+
#expect(isFocused(node) == true)
70+
// and blurring clears it
71+
host.callbacks.invokeBool(focusCallback(node) ?? callback, false)
72+
node = host.evaluate()
73+
#expect(isFocused(node) == false)
74+
}
75+
76+
@Test("focused(equals:) arbitrates between fields and a sibling's blur can't steal focus")
77+
func focusStateEquals() {
78+
enum Field: Hashable { case first, second }
79+
struct Screen: View {
80+
@State var a = ""
81+
@State var b = ""
82+
@FocusState var focus: Field? = nil
83+
var body: some View {
84+
VStack {
85+
TextField("A", text: $a).focused($focus, equals: .first)
86+
TextField("B", text: $b).focused($focus, equals: .second)
87+
}
88+
}
89+
}
90+
let host = ViewHost(Screen())
91+
var node = host.evaluate()
92+
#expect(isFocused(node.children[0]) == false)
93+
#expect(isFocused(node.children[1]) == false)
94+
guard let firstCallback = focusCallback(node.children[0]),
95+
let secondCallback = focusCallback(node.children[1]) else {
96+
Issue.record("missing focus callbacks"); return
97+
}
98+
// second field takes focus
99+
host.callbacks.invokeBool(secondCallback, true)
100+
node = host.evaluate()
101+
#expect(isFocused(node.children[0]) == false)
102+
#expect(isFocused(node.children[1]) == true)
103+
// the first field's late blur must NOT clear the second field's focus
104+
host.callbacks.invokeBool(firstCallback, false)
105+
node = host.evaluate()
106+
#expect(isFocused(node.children[1]) == true)
107+
}
108+
51109
@Test("Picker emits tagged children and maps the selection string back")
52110
func picker() {
53111
struct Screen: View {
@@ -215,3 +273,17 @@ struct ControlTests {
215273
#expect(node.props["millis"] == .double(changed.timeIntervalSince1970 * 1000))
216274
}
217275
}
276+
277+
/// Whether a field's `focused` modifier currently claims focus.
278+
private func isFocused(_ node: RenderNode) -> Bool? {
279+
guard let mod = node.modifiers.first(where: { $0.kind == "focused" }),
280+
case .bool(let value)? = mod.args["isFocused"] else { return nil }
281+
return value
282+
}
283+
284+
/// The callback a field's `focused` modifier reports focus changes through.
285+
private func focusCallback(_ node: RenderNode) -> Int64? {
286+
guard let mod = node.modifiers.first(where: { $0.kind == "focused" }),
287+
case .int(let id)? = mod.args["onChange"] else { return nil }
288+
return Int64(id)
289+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct CatalogEntry: Identifiable {
3030
CatalogEntry(id: "toggle", title: "Toggle", screen: AnyCatalogScreen(TogglePlayground())),
3131
CatalogEntry(id: "slider", title: "Slider", screen: AnyCatalogScreen(SliderPlayground())),
3232
CatalogEntry(id: "textfield", title: "TextField", screen: AnyCatalogScreen(TextFieldPlayground())),
33+
CatalogEntry(id: "focus", title: "FocusState", screen: AnyCatalogScreen(FocusPlayground())),
3334
CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())),
3435
CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())),
3536
CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct FocusPlayground: View {
8+
9+
private enum Field: Hashable { case name, email }
10+
11+
@State private var name = ""
12+
@State private var email = ""
13+
@FocusState private var focus: Field?
14+
15+
private var focusLabel: String {
16+
switch focus {
17+
case .name: return "Name"
18+
case .email: return "Email"
19+
case nil: return "none"
20+
}
21+
}
22+
23+
var body: some View {
24+
ScrollView {
25+
VStack(alignment: .leading, spacing: 16) {
26+
Text("Focused field: \(focusLabel)")
27+
28+
TextField("Name", text: $name)
29+
.focused($focus, equals: .name)
30+
TextField("Email", text: $email)
31+
.focused($focus, equals: .email)
32+
33+
// driving focus from Swift
34+
Button("Focus name") { focus = .name }
35+
Button("Focus email") { focus = .email }
36+
Button("Dismiss keyboard") { focus = nil }
37+
}
38+
.padding()
39+
}
40+
.navigationTitle("FocusState")
41+
}
42+
}

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ import androidx.compose.ui.draw.scale
131131
import androidx.compose.ui.draw.shadow
132132
import androidx.compose.ui.graphics.Brush
133133
import androidx.compose.ui.graphics.Color
134+
import androidx.compose.ui.focus.FocusRequester
135+
import androidx.compose.ui.focus.focusRequester
136+
import androidx.compose.ui.focus.onFocusChanged
137+
import androidx.compose.ui.platform.LocalFocusManager
134138
import androidx.compose.ui.graphics.RectangleShape
135139
import androidx.compose.ui.graphics.vector.ImageVector
136140
import androidx.compose.foundation.shape.CircleShape
@@ -434,6 +438,27 @@ private fun RenderTextField(node: ViewNode) {
434438
local = TextFieldValue(swiftText, selection = androidx.compose.ui.text.TextRange(swiftText.length))
435439
lastSent = swiftText
436440
}
441+
// `.focused` binding: Swift drives focus through `isFocused`, and the field
442+
// reports back when the user moves focus themselves.
443+
val focus = node.modifiers.firstOrNull { it.kind == "focused" }
444+
val shouldFocus = focus?.args?.string("isFocused") == "true"
445+
val onFocusChange = focus?.args?.long("onChange")
446+
val requester = remember(node.id) { FocusRequester() }
447+
val focusManager = LocalFocusManager.current
448+
var hasFocus by remember(node.id) { mutableStateOf(false) }
449+
450+
var fieldModifier = node.composeModifiers().fillMaxWidth()
451+
if (focus != null) {
452+
fieldModifier = fieldModifier
453+
.focusRequester(requester)
454+
.onFocusChanged { state ->
455+
if (state.isFocused != hasFocus) {
456+
hasFocus = state.isFocused
457+
onFocusChange?.let { SwiftBridge.sink.invokeBool(it, state.isFocused) }
458+
}
459+
}
460+
}
461+
437462
OutlinedTextField(
438463
value = local,
439464
onValueChange = { v ->
@@ -446,8 +471,15 @@ private fun RenderTextField(node: ViewNode) {
446471
label = { Text(node.string("placeholder") ?: "") },
447472
enabled = node.isEnabled(),
448473
visualTransformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None,
449-
modifier = node.composeModifiers().fillMaxWidth(),
474+
modifier = fieldModifier,
450475
)
476+
477+
if (focus != null) {
478+
LaunchedEffect(shouldFocus) {
479+
if (shouldFocus && !hasFocus) requester.requestFocus()
480+
else if (!shouldFocus && hasFocus) focusManager.clearFocus()
481+
}
482+
}
451483
}
452484

453485
// Label, then − / + edge buttons pushed to the trailing side.
@@ -1123,7 +1155,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
11231155
"scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled",
11241156
"font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment",
11251157
"tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem",
1126-
"transition",
1158+
"transition", "focused",
11271159
)
11281160

11291161
// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded

0 commit comments

Comments
 (0)