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
Original file line number Diff line number Diff line change
@@ -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<Self, _KeyboardTypeModifier> {
modifier(_KeyboardTypeModifier(type: type))
}

func submitLabel(_ label: SubmitLabel) -> ModifiedContent<Self, _SubmitLabelModifier> {
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<Self, _OnSubmitModifier> {
modifier(_OnSubmitModifier(action: action))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
42 changes: 42 additions & 0 deletions Demo/App.swiftpm/Sources/FormInputPlaygrounds.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 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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -704,6 +740,9 @@ private fun RenderTextField(node: ViewNode) {
label = label,
enabled = node.isEnabled(),
visualTransformation = transformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = true,
modifier = fieldModifier,
)
}
Expand Down Expand Up @@ -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
Expand Down
Loading