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
115 changes: 115 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/FocusState.swift
Original file line number Diff line number Diff line change
@@ -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<Value: Hashable>: DynamicProperty {

internal let box: StateBox<Value>

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<Value>

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<Bool>.Binding) -> ModifiedContent<Self, _FocusedModifier> {
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<V: Hashable>(
_ binding: FocusState<V?>.Binding,
equals value: V
) -> ModifiedContent<Self, _FocusedModifier> {
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
}
}
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
42 changes: 42 additions & 0 deletions Demo/App.swiftpm/Sources/FocusPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
36 changes: 34 additions & 2 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading