Skip to content

Commit 8b08220

Browse files
authored
Merge pull request #63 from PureSwift/feature/form-input
2 parents d350cb7 + 4b33193 commit 8b08220

5 files changed

Lines changed: 196 additions & 1 deletion

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// KeyboardModifiers.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Soft-keyboard configuration for text entry. A field reads these off its own
6+
// modifier list (like `.focused`): the type chooses the key layout, the submit
7+
// label names the action key, and `onSubmit` fires when that key is pressed.
8+
//
9+
10+
/// The soft keyboard a text field requests. Named to match SwiftUI's
11+
/// `UIKeyboardType` so call sites port unchanged.
12+
public enum UIKeyboardType: String, Sendable {
13+
case `default`
14+
case asciiCapable
15+
case numbersAndPunctuation
16+
case URL
17+
case numberPad
18+
case phonePad
19+
case namePhonePad
20+
case emailAddress
21+
case decimalPad
22+
case twitter
23+
case webSearch
24+
case asciiCapableNumberPad
25+
}
26+
27+
/// The label on the keyboard's action key.
28+
public struct SubmitLabel: Sendable {
29+
internal let kind: String
30+
public static let done = SubmitLabel(kind: "done")
31+
public static let go = SubmitLabel(kind: "go")
32+
public static let send = SubmitLabel(kind: "send")
33+
public static let join = SubmitLabel(kind: "join")
34+
public static let route = SubmitLabel(kind: "route")
35+
public static let search = SubmitLabel(kind: "search")
36+
public static let `return` = SubmitLabel(kind: "return")
37+
public static let next = SubmitLabel(kind: "next")
38+
public static let `continue` = SubmitLabel(kind: "continue")
39+
}
40+
41+
public struct _KeyboardTypeModifier: RenderModifier {
42+
let type: UIKeyboardType
43+
public var _modifierNode: ModifierNode {
44+
ModifierNode(kind: "keyboardType", args: ["type": .string(type.rawValue)])
45+
}
46+
}
47+
48+
public struct _SubmitLabelModifier: RenderModifier {
49+
let label: SubmitLabel
50+
public var _modifierNode: ModifierNode {
51+
ModifierNode(kind: "submitLabel", args: ["label": .string(label.kind)])
52+
}
53+
}
54+
55+
public struct _OnSubmitModifier: RenderModifier, _CallbackModifier {
56+
let action: () -> Void
57+
public var _modifierNode: ModifierNode { ModifierNode(kind: "onSubmit") }
58+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
59+
let id = context.callbacks.register(.void(action))
60+
return ModifierNode(kind: "onSubmit", args: ["action": .int(Int(id))])
61+
}
62+
}
63+
64+
public extension View {
65+
66+
func keyboardType(_ type: UIKeyboardType) -> ModifiedContent<Self, _KeyboardTypeModifier> {
67+
modifier(_KeyboardTypeModifier(type: type))
68+
}
69+
70+
func submitLabel(_ label: SubmitLabel) -> ModifiedContent<Self, _SubmitLabelModifier> {
71+
modifier(_SubmitLabelModifier(label: label))
72+
}
73+
74+
/// Runs `action` when the user submits the text field (presses the keyboard
75+
/// action key).
76+
func onSubmit(perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnSubmitModifier> {
77+
modifier(_OnSubmitModifier(action: action))
78+
}
79+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,40 @@ struct ControlTests {
195195
#expect(field.modifiers.first { $0.kind == "textFieldStyle" }?.args["style"] == .string("plain"))
196196
}
197197

198+
@Test("Keyboard type and submit label emit their spelling")
199+
func keyboardConfig() {
200+
struct Screen: View {
201+
@State var text = ""
202+
var body: some View {
203+
TextField("Amount", text: $text)
204+
.keyboardType(.decimalPad)
205+
.submitLabel(.search)
206+
}
207+
}
208+
let node = ViewHost(Screen()).evaluate()
209+
#expect(node.modifiers.first { $0.kind == "keyboardType" }?.args["type"] == .string("decimalPad"))
210+
#expect(node.modifiers.first { $0.kind == "submitLabel" }?.args["label"] == .string("search"))
211+
}
212+
213+
@Test("onSubmit registers a callback the field can fire")
214+
func onSubmit() {
215+
var submitted = false
216+
struct Screen: View {
217+
let onSubmit: () -> Void
218+
@State var text = ""
219+
var body: some View {
220+
TextField("Search", text: $text).onSubmit(perform: onSubmit)
221+
}
222+
}
223+
let host = ViewHost(Screen(onSubmit: { submitted = true }))
224+
let node = host.evaluate()
225+
guard case .int(let id)? = node.modifiers.first(where: { $0.kind == "onSubmit" })?.args["action"] else {
226+
Issue.record("missing onSubmit callback"); return
227+
}
228+
host.callbacks.invokeVoid(Int64(id))
229+
#expect(submitted)
230+
}
231+
198232
@Test("Picker emits tagged children and maps the selection string back")
199233
func picker() {
200234
struct Screen: View {

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ struct CatalogEntry: Identifiable {
3131
CatalogEntry(id: "slider", title: "Slider", screen: AnyCatalogScreen(SliderPlayground())),
3232
CatalogEntry(id: "textfield", title: "TextField", screen: AnyCatalogScreen(TextFieldPlayground())),
3333
CatalogEntry(id: "focus", title: "FocusState", screen: AnyCatalogScreen(FocusPlayground())),
34+
CatalogEntry(id: "forminput", title: "Form Input", screen: AnyCatalogScreen(FormInputPlayground())),
3435
CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())),
3536
CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())),
3637
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 FormInputPlayground: View {
8+
9+
@State private var amount = ""
10+
@State private var email = ""
11+
@State private var query = ""
12+
@State private var submissions = 0
13+
@State private var lastSubmitted = ""
14+
15+
var body: some View {
16+
ScrollView {
17+
VStack(alignment: .leading, spacing: 0) {
18+
Example("keyboardType(.decimalPad)") {
19+
TextField("Amount", text: $amount)
20+
.keyboardType(.decimalPad)
21+
}
22+
Example("keyboardType(.emailAddress)") {
23+
TextField("Email", text: $email)
24+
.keyboardType(.emailAddress)
25+
}
26+
Example("submitLabel(.search) + onSubmit") {
27+
VStack(alignment: .leading, spacing: 8) {
28+
TextField("Search", text: $query)
29+
.submitLabel(.search)
30+
.onSubmit {
31+
submissions += 1
32+
lastSubmitted = query
33+
}
34+
Text("Submitted \(submissions) time(s)")
35+
Text("Last: \(lastSubmitted)")
36+
}
37+
}
38+
}
39+
}
40+
.navigationTitle("Form Input")
41+
}
42+
}

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ import androidx.compose.ui.text.font.FontStyle
173173
import androidx.compose.ui.text.font.FontWeight
174174
import androidx.compose.ui.text.style.TextAlign
175175
import androidx.compose.ui.text.input.PasswordVisualTransformation
176+
import androidx.compose.foundation.text.KeyboardOptions
177+
import androidx.compose.foundation.text.KeyboardActions
178+
import androidx.compose.ui.text.input.KeyboardType
179+
import androidx.compose.ui.text.input.ImeAction
176180
import androidx.compose.ui.text.input.TextFieldValue
177181
import androidx.compose.ui.text.input.VisualTransformation
178182
import androidx.compose.ui.unit.TextUnit
@@ -681,6 +685,35 @@ private fun RenderTextField(node: ViewNode) {
681685
val label: @Composable () -> Unit = { Text(node.string("placeholder") ?: "") }
682686
val transformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None
683687

688+
// .keyboardType chooses the key layout; .submitLabel names the action key.
689+
val keyboardType = when (node.modifiers.firstOrNull { it.kind == "keyboardType" }?.args?.string("type")) {
690+
"numberPad", "asciiCapableNumberPad" -> KeyboardType.Number
691+
"decimalPad" -> KeyboardType.Decimal
692+
"phonePad", "namePhonePad" -> KeyboardType.Phone
693+
"emailAddress" -> KeyboardType.Email
694+
"URL" -> KeyboardType.Uri
695+
else -> KeyboardType.Text
696+
}
697+
val imeAction = when (node.modifiers.firstOrNull { it.kind == "submitLabel" }?.args?.string("label")) {
698+
"search", "webSearch" -> ImeAction.Search
699+
"go", "route" -> ImeAction.Go
700+
"send" -> ImeAction.Send
701+
"next" -> ImeAction.Next
702+
"join", "continue", "return" -> ImeAction.Done
703+
else -> ImeAction.Default
704+
}
705+
val keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction)
706+
707+
// .onSubmit fires on the keyboard action key, whatever its label.
708+
val onSubmit = node.modifiers.firstOrNull { it.kind == "onSubmit" }?.args?.long("action")
709+
val keyboardActions = KeyboardActions(
710+
onDone = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } },
711+
onGo = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } },
712+
onSend = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } },
713+
onSearch = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } },
714+
onNext = { onSubmit?.let { SwiftBridge.sink.invokeVoid(it) } },
715+
)
716+
684717
// plain drops the box outline; roundedBorder and automatic keep it
685718
if (LocalTextFieldStyle.current == "plain") {
686719
TextField(
@@ -689,6 +722,9 @@ private fun RenderTextField(node: ViewNode) {
689722
label = label,
690723
enabled = node.isEnabled(),
691724
visualTransformation = transformation,
725+
keyboardOptions = keyboardOptions,
726+
keyboardActions = keyboardActions,
727+
singleLine = true,
692728
colors = TextFieldDefaults.colors(
693729
focusedIndicatorColor = Color.Transparent,
694730
unfocusedIndicatorColor = Color.Transparent,
@@ -704,6 +740,9 @@ private fun RenderTextField(node: ViewNode) {
704740
label = label,
705741
enabled = node.isEnabled(),
706742
visualTransformation = transformation,
743+
keyboardOptions = keyboardOptions,
744+
keyboardActions = keyboardActions,
745+
singleLine = true,
707746
modifier = fieldModifier,
708747
)
709748
}
@@ -1572,7 +1611,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
15721611
"transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle",
15731612
"buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle",
15741613
"accessibilityLabel", "accessibilityValue", "accessibilityHidden",
1575-
"accessibilityAddTraits", "accessibilityIdentifier", "labelStyle",
1614+
"accessibilityAddTraits", "accessibilityIdentifier", "keyboardType", "submitLabel", "onSubmit", "labelStyle",
15761615
)
15771616

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

0 commit comments

Comments
 (0)