SwiftUIのTextFieldに入力した1文字目が変換対象にならない問題とその対処法
SwiftUIのTextFieldに日本語入力キーボードで文字を入力すると、1 文字目が変換対象にならないことがあります。
例えば、キーボードで「a」「u」と入力すると、1文字目の「あ」が変換対象にならない場合があります。(sheet (opens in a new tab)の上などにTextFieldを配置すると、高確率で発生)
この現象はUIKitのUITextFieldでは発生しないので、SwiftUIのバグです。
対処法: UITextFieldをラップした TextField2
を作る
TextField2.swift
import SwiftUI
import UIKit
final class TextField2Coordinator: NSObject, UITextFieldDelegate {
var control: TextField2
init(_ control: TextField2) {
self.control = control
super.init()
control.textField.addTarget(self, action: #selector(textFieldEditingDidBegin(_:)), for: .editingDidBegin)
control.textField.addTarget(self, action: #selector(textFieldEditingDidEnd(_:)), for: .editingDidEnd)
control.textField.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
control.textField.addTarget(self, action: #selector(textFieldEditingDidEndOnExit(_:)), for: .editingDidEndOnExit)
}
@objc private func textFieldEditingDidBegin(_ textField: UITextField) {
control.onEditingChanged(true)
}
@objc private func textFieldEditingDidEnd(_ textField: UITextField) {
control.onEditingChanged(false)
}
@objc private func textFieldEditingChanged(_ textField: UITextField) {
control.text = textField.text ?? ""
}
@objc private func textFieldEditingDidEndOnExit(_ textField: UITextField) {
control.onCommit()
}
@objc func onCancel(_ button: UIButton) {
control.onCancel?()
}
}
struct TextField2: UIViewRepresentable {
private let title: String?
@Binding var text: String
let textField = UITextField()
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void
var onCancel: (() -> Void)? = nil
private var keyboardType: UIKeyboardType = .default
private var clearButtonMode: UITextField.ViewMode = .always
init(_ title: String?,
text: Binding<String>,
onEditingChanged: @escaping (Bool) -> Void = { _ in },
onCommit: @escaping () -> Void = {}
) {
self.title = title
self._text = text
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
}
func makeCoordinator() -> TextField2Coordinator {
TextField2Coordinator(self)
}
func makeUIView(context: Context) -> UITextField {
// TextFieldのコンテンツが領域をはみ出さないようにする
// https://stackoverflow.com/a/59193838
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.placeholder = title
textField.delegate = context.coordinator
textField.clearButtonMode = clearButtonMode
textField.keyboardType = keyboardType
if onCancel != nil {
textField.inputAccessoryView = makeToolbar(context: context)
}
return textField
}
// NOTE: @Bindingの値が変更された時、updateUIViewが呼び出される
func updateUIView(_ uiView: UITextField, context: Context) {
// NOTE: 変換候補がない場合(markedTextRange == nil)のみtextをセットする.
// この条件がないと、ユーザが入力した1文字目が変換対象にならないことがある.
if uiView.text != text && uiView.markedTextRange == nil {
uiView.text = text
}
}
private func makeToolbar(context: Context) -> UIToolbar {
let toolbar = UIToolbar(frame: CGRect(x: .zero, y: .zero, width: textField.frame.size.width, height: 44))
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let cancelButton = UIBarButtonItem(
title: "Cancel",
style: .plain,
target: context.coordinator,
action: #selector(context.coordinator.onCancel(_:))
)
toolbar.setItems([spacer, cancelButton], animated: true)
return toolbar
}
}
extension TextField2 {
func keyboardType(_ keyboardType: UIKeyboardType) -> TextField2 {
var view = self
view.keyboardType = keyboardType
return view
}
func clearButtonMode(_ clearButtonMode: UITextField.ViewMode) -> TextField2 {
var view = self
view.clearButtonMode = clearButtonMode
return view
}
func onCancel(_ action: @escaping () -> Void) -> TextField2 {
var view = self
view.onCancel = action
return view
}
}
問題解決の鍵となるのが以下の部分で、変換候補がない場合(markedTextRange (opens in a new tab) == nil)のみUITextFieldのtextをセットするようにしています。
// NOTE: @Bindingの値が変更された時、updateUIViewが呼び出される
func updateUIView(_ uiView: UITextField, context: Context) {
// NOTE: 変換候補がない場合(markedTextRange == nil)のみtextをセットする.
// この条件がないと、ユーザが入力した1文字目が変換対象にならないことがある.
if uiView.text != text && uiView.markedTextRange == nil {
uiView.text = text
}
}
このTextView2で出来ること・出来ないことは以下の通りです。
TextView2で出来ること
- title(プレースホルダー)の指定
- onEditingChangedイベントの受信
- onCommitイベントの受信
- キーボードのToolbarにCancelボタンを表示(modifierで
onCancel
を指定した時のみ表示されます) - keyboardType (opens in a new tab)の指定
- clearButtonMode (opens in a new tab)の指定
TextView2で出来ないこと
- 複数行入力
- SwiftUIのTextFieldは
axis: .vertical
を指定することで複数行の入力が可能ですが、TextField2は複数行に対応していません。 - 元になっているUITextFieldが複数行に対応していないためです。
- 試していませんが、UITextViewをラップすれば複数行に対応できるかもしれません。
- SwiftUIのTextFieldは
使用例
比較のためSwiftUIのTextField と TextField2 を上下に並べてみました。
ContentView.swift
import SwiftUI
struct ContentView: View {
enum Field: Hashable {
case keyword1
case keyword2
}
@State private var keyword1 = ""
@State private var keyword2 = ""
@FocusState private var focusedField: Field?
var body: some View {
VStack(spacing: 0) {
List {
Section(header: EmptyView()) {
TextField("TextField", text: $keyword1)
.onChange(of: keyword1) { keyword in
print("onChangeKeword1: \(keyword)")
}
.focused($focusedField, equals: .keyword1)
.toolbar {
ToolbarItem(placement: .keyboard) {
HStack {
Spacer()
Button("Cancel"){
self.focusedField = nil
}
}
}
}
TextField2("TextField2",
text: $keyword2,
onEditingChanged: { isEditing in
print("onEditingChanged: \(isEditing)")
},
onCommit: {
print("onCommit")
})
.clearButtonMode(.whileEditing)
.onCancel { focusedField = nil }
.onChange(of: keyword2) { keyword in
print("onChangeKeword2: \(keyword)")
}
.focused($focusedField, equals: .keyword2)
}
}
}
}
}
#Preview {
ContentView()
}
Github
https://github.com/k-yamada/SwiftUITextFieldSample (opens in a new tab)
© 品川アプリ.RSS