Challenges with Multiple TextInput Components
- Initial Focus: The system does not automatically determine which input field should receive focus upon initialization. This can be partially resolved by adding
.defaultFocus(index == 0)to the first input. - Deletion Handling: The
onChangeevent often fails to trigger when a user deletes characters (observed on simulators, tablets, and phones). Attempting to useonKeyEventto listen for the delete key (KeyCode.KEYCODE_DEL) also proves unreliable across devices. - Soft Kebyoard Behavior: Using
focusControl.requestFocus(nextKeyStr)to move to the next field sometimes causes the soft keyboard to dismiss unexpectedly, leading to a poor user experience.
The Alternative: Single TextInput with Overlayed Text
Due to the limitations above, a more robust solution is to use a single hidden TextInput layered over multiple Text components. The Text components display the code visually, while the invisible TextInput handles the actual user interaction.
Here is the implementation of a CodeInputView component using this strategy:
@Preview
@Component
export struct CodeInputView {
@State digitCount: number = 4
inputResultCallback: (string) => void
@Link userDigits: Array<string>
@State showCursor: boolean = true
private inputIdentifier = "hidden_code_field"
aboutToAppear() {
if (this.userDigits == null) {
this.userDigits = new Array(this.digitCount).fill('');
}
}
build() {
Stack() {
if (this.userDigits != null) {
Row({ space: vp(10) }) {
ForEach(this.userDigits, (item: string, index: number) => {
Text(item)
.backgroundColor($r('app.color.white_80'))
.height(match())
.layoutWeight(1)
.fontSize(fp(25))
.textAlign(TextAlign.Center)
.align(Alignment.Center)
.borderRadius(vp(15))
.focusable(false)
.defaultFocus(false)
.focusOnTouch(false)
.onClick(() => {
focusControl.requestFocus(this.inputIdentifier)
})
}, (item: string) => item)
}
.height(match())
.width(match())
TextInput()
.maxLength(this.digitCount)
.fontSize(fp(25))
.borderRadius(vp(15))
.type(InputType.Number)
.key(this.inputIdentifier)
.onChange((value) => {
let chars = value.split('')
this.userDigits.forEach((val, idx) => {
this.userDigits[idx] = chars[idx] || ''
})
if (chars.length >= this.digitCount) {
this.inputResultCallback(value)
}
this.showCursor = (chars.length == 0)
})
.copyOption(CopyOptions.None)
.caretColor(this.showCursor ? Color.Black : Color.Transparent)
.fontColor(Color.Transparent)
.backgroundColor(Color.Transparent)
.height(match())
.width(match())
}
}
.height(vp(80))
}
}
</string>
Key Implementation Details
- Transparent Input: The
TextInputis set to fill the entire container with transparent text, background, and cursor (when not needed) to hide it from the user. - Visual Display:
ForEachrenders a row ofTextcomponants. These act as the visual boxes for the verification code. - Data Binding: In the
onChangeevent of the hidden input, the string is split into an array of characters. These characters are mapped to theuserDigitsarray, which updates theTextcomponents automatically. - Completion Callback: Once the length of the input matches
digitCount, theinputResultCallbackis triggered to pass the result to the parent component.
To use this component in a parent view:
CodeInputView({
inputResultCallback: (code) => {
// Handle the completed verification code
console.log("Entered code: " + code)
}
})
This approach circumvents the focus and keyboard issues associated with multiple enputs. While there is a minor visual artifact where the background flashes if the state is not perfectly matched, this method provides the most stable experience currently available in ArkUI.