diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/KeyboardModifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/KeyboardModifiers.swift new file mode 100644 index 0000000..af3b9eb --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/KeyboardModifiers.swift @@ -0,0 +1,79 @@ +// +// KeyboardModifiers.swift +// AndroidSwiftUICore +// +// Soft-keyboard configuration for text entry. A field reads these off its own +// modifier list (like `.focused`): the type chooses the key layout, the submit +// label names the action key, and `onSubmit` fires when that key is pressed. +// + +/// The soft keyboard a text field requests. Named to match SwiftUI's +/// `UIKeyboardType` so call sites port unchanged. +public enum UIKeyboardType: String, Sendable { + case `default` + case asciiCapable + case numbersAndPunctuation + case URL + case numberPad + case phonePad + case namePhonePad + case emailAddress + case decimalPad + case twitter + case webSearch + case asciiCapableNumberPad +} + +/// The label on the keyboard's action key. +public struct SubmitLabel: Sendable { + internal let kind: String + public static let done = SubmitLabel(kind: "done") + public static let go = SubmitLabel(kind: "go") + public static let send = SubmitLabel(kind: "send") + public static let join = SubmitLabel(kind: "join") + public static let route = SubmitLabel(kind: "route") + public static let search = SubmitLabel(kind: "search") + public static let `return` = SubmitLabel(kind: "return") + public static let next = SubmitLabel(kind: "next") + public static let `continue` = SubmitLabel(kind: "continue") +} + +public struct _KeyboardTypeModifier: RenderModifier { + let type: UIKeyboardType + public var _modifierNode: ModifierNode { + ModifierNode(kind: "keyboardType", args: ["type": .string(type.rawValue)]) + } +} + +public struct _SubmitLabelModifier: RenderModifier { + let label: SubmitLabel + public var _modifierNode: ModifierNode { + ModifierNode(kind: "submitLabel", args: ["label": .string(label.kind)]) + } +} + +public struct _OnSubmitModifier: RenderModifier, _CallbackModifier { + let action: () -> Void + public var _modifierNode: ModifierNode { ModifierNode(kind: "onSubmit") } + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.void(action)) + return ModifierNode(kind: "onSubmit", args: ["action": .int(Int(id))]) + } +} + +public extension View { + + func keyboardType(_ type: UIKeyboardType) -> ModifiedContent { + modifier(_KeyboardTypeModifier(type: type)) + } + + func submitLabel(_ label: SubmitLabel) -> ModifiedContent { + modifier(_SubmitLabelModifier(label: label)) + } + + /// Runs `action` when the user submits the text field (presses the keyboard + /// action key). + func onSubmit(perform action: @escaping () -> Void) -> ModifiedContent { + modifier(_OnSubmitModifier(action: action)) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index 356d6c5..61e43a8 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -195,6 +195,40 @@ struct ControlTests { #expect(field.modifiers.first { $0.kind == "textFieldStyle" }?.args["style"] == .string("plain")) } + @Test("Keyboard type and submit label emit their spelling") + func keyboardConfig() { + struct Screen: View { + @State var text = "" + var body: some View { + TextField("Amount", text: $text) + .keyboardType(.decimalPad) + .submitLabel(.search) + } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.modifiers.first { $0.kind == "keyboardType" }?.args["type"] == .string("decimalPad")) + #expect(node.modifiers.first { $0.kind == "submitLabel" }?.args["label"] == .string("search")) + } + + @Test("onSubmit registers a callback the field can fire") + func onSubmit() { + var submitted = false + struct Screen: View { + let onSubmit: () -> Void + @State var text = "" + var body: some View { + TextField("Search", text: $text).onSubmit(perform: onSubmit) + } + } + let host = ViewHost(Screen(onSubmit: { submitted = true })) + let node = host.evaluate() + guard case .int(let id)? = node.modifiers.first(where: { $0.kind == "onSubmit" })?.args["action"] else { + Issue.record("missing onSubmit callback"); return + } + host.callbacks.invokeVoid(Int64(id)) + #expect(submitted) + } + @Test("Picker emits tagged children and maps the selection string back") func picker() { struct Screen: View { diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index f8dd3f1..1e1e953 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -31,6 +31,7 @@ struct CatalogEntry: Identifiable { 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: "forminput", title: "Form Input", screen: AnyCatalogScreen(FormInputPlayground())), 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/FormInputPlaygrounds.swift b/Demo/App.swiftpm/Sources/FormInputPlaygrounds.swift new file mode 100644 index 0000000..3a00bb0 --- /dev/null +++ b/Demo/App.swiftpm/Sources/FormInputPlaygrounds.swift @@ -0,0 +1,42 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct FormInputPlayground: View { + + @State private var amount = "" + @State private var email = "" + @State private var query = "" + @State private var submissions = 0 + @State private var lastSubmitted = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("keyboardType(.decimalPad)") { + TextField("Amount", text: $amount) + .keyboardType(.decimalPad) + } + Example("keyboardType(.emailAddress)") { + TextField("Email", text: $email) + .keyboardType(.emailAddress) + } + Example("submitLabel(.search) + onSubmit") { + VStack(alignment: .leading, spacing: 8) { + TextField("Search", text: $query) + .submitLabel(.search) + .onSubmit { + submissions += 1 + lastSubmitted = query + } + Text("Submitted \(submissions) time(s)") + Text("Last: \(lastSubmitted)") + } + } + } + } + .navigationTitle("Form Input") + } +} 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 60a124d..59a83eb 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -173,6 +173,10 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.TextUnit @@ -681,6 +685,35 @@ private fun RenderTextField(node: ViewNode) { val label: @Composable () -> Unit = { Text(node.string("placeholder") ?: "") } val transformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None + // .keyboardType chooses the key layout; .submitLabel names the action key. + val keyboardType = when (node.modifiers.firstOrNull { it.kind == "keyboardType" }?.args?.string("type")) { + "numberPad", "asciiCapableNumberPad" -> KeyboardType.Number + "decimalPad" -> KeyboardType.Decimal + "phonePad", "namePhonePad" -> KeyboardType.Phone + "emailAddress" -> KeyboardType.Email + "URL" -> KeyboardType.Uri + else -> KeyboardType.Text + } + val imeAction = when (node.modifiers.firstOrNull { it.kind == "submitLabel" }?.args?.string("label")) { + "search", "webSearch" -> ImeAction.Search + "go", "route" -> ImeAction.Go + "send" -> ImeAction.Send + "next" -> ImeAction.Next + "join", "continue", "return" -> ImeAction.Done + else -> ImeAction.Default + } + val keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction) + + // .onSubmit fires on the keyboard action key, whatever its label. + val onSubmit = node.modifiers.firstOrNull { it.kind == "onSubmit" }?.args?.long("action") + val keyboardActions = KeyboardActions( + onDone = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } }, + onGo = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } }, + onSend = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } }, + onSearch = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } }, + onNext = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } }, + ) + // plain drops the box outline; roundedBorder and automatic keep it if (LocalTextFieldStyle.current == "plain") { TextField( @@ -689,6 +722,9 @@ private fun RenderTextField(node: ViewNode) { label = label, enabled = node.isEnabled(), visualTransformation = transformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = true, colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, @@ -704,6 +740,9 @@ private fun RenderTextField(node: ViewNode) { label = label, enabled = node.isEnabled(), visualTransformation = transformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = true, modifier = fieldModifier, ) } @@ -1572,7 +1611,7 @@ private val KNOWN_MODIFIER_KINDS = setOf( "transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle", "buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle", "accessibilityLabel", "accessibilityValue", "accessibilityHidden", - "accessibilityAddTraits", "accessibilityIdentifier", "labelStyle", + "accessibilityAddTraits", "accessibilityIdentifier", "keyboardType", "submitLabel", "onSubmit", "labelStyle", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded