SwiftUI.TextField に formatter を指定した場合、return を押さないと値が反映されない問題への対処

4 min read読了の目安(約4400字

概要

SwiftUI.TextField には formatter を指定する便利なイニシャライザが存在します。

しかし、このイニシャライザを使った場合は、return キーを押すタイミングでしか値が反映されないため、return を押さずにキーボードを閉じたり、他の入力欄に移動するなどした場合、入力していた内容が @State に反映されていない状態になってしまいました。

この記事ではその問題への対処方法を記載します。

検証環境

  • iOS 14.6
  • Xcode 12.5

問題のサンプル

例えば、以下のように TextField にカンマ区切りの金額を表示したいとします。
Submit を押すと入力欄のフォーカスが外れます。このタイミングで、@State var value: Int に 値が反映されて欲しいのですが、反映されません。

2000 と入力して return を押すと 2,000 円 となってくれますが、3000 と入力して return を押さずに Submit を押すと、入力は終了しているのに、TextField は 3000 と表示されており、Value: 2000Text に表示されているように、value 変数には反映されていません。

import SwiftUI

private let numberFormatter: NumberFormatter = {
    let f = NumberFormatter()
    f.locale = Locale(identifier: "ja_JP")
    f.numberStyle = .currencyPlural
    f.isLenient = true
    return f
}()

struct ContentView: View {
    @State var value: Int = 1000
    
    var body: some View {
        VStack {
            TextField(
                "金額",
                value: $value,
                formatter: numberFormatter,
		onCommit: { print("commit") /* return を押すと onCommit が呼ばれる */ }
            )
            
            Text("Value: \(value.description)")
            
            Button("Submit") {
                UIApplication.shared.endEditing()
            }
        }
        .textFieldStyle(RoundedBorderTextFieldStyle())
    }
}

extension UIApplication {
    func endEditing() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

対処方法

良くない副作用があるかもしれませんが、強制的に return を押した時と同じイベントを発生させることができないかと考え、以下のように UITextField を取得し、UIControl.Event.editingDidEndOnExit を送信してみました。

struct ContentView: View {
    @State var value: Int = 1000
    
    var body: some View {
        VStack {
            TextField(
                "金額",
                value: $value,
                formatter: numberFormatter,
                onCommit: { print("commit") /* textField.sendActions(for: .editingDidEndOnExit) を呼ぶと onCommit が呼ばれる */ }
            )
            
            Text("Value: \(value.description)")
            
            Button("Submit") {
                UIApplication.shared.endEditing()
            }
        }
        .textFieldStyle(RoundedBorderTextFieldStyle())
	// 編集終了時のイベント
        .onReceive(
            NotificationCenter.default.publisher(for: UITextField.textDidEndEditingNotification),
            perform: textDidEndEditing
        )
    }
    
    func textDidEndEditing(_ notification: Notification) {
        let textField = notification.object as! UITextField
	// 以下を実行することで return を押した時と同じく onCommit が呼ばれている
        textField.sendActions(for: .editingDidEndOnExit)
    }
}

上記のままだと、onCommit が呼ばれてほしくない TextField にも適用されてしまうため、何らかの工夫が必要かもしれません。以下は onCommit を強制的に行いたい場合にフラグを立てる例です。

struct ContentView: View {
    @State var value: Int = 1000
    // onCommit を強制的に呼び出したい場合にフラグを立てる
    @State var shouldCommit = false
    
    var body: some View {
        VStack {
            TextField(
                "金額",
                value: $value,
                formatter: numberFormatter,
                onEditingChanged: { editing in
                    if !editing {
		        // この入力欄は強制的に onCommit を発生させたい
                        shouldCommit = true
                    }
                },
                onCommit: { print("commit") }
            )
            
            Text("Value: \(value.description)")
            
            Button("Submit") {
                UIApplication.shared.endEditing()
            }
        }
        .textFieldStyle(RoundedBorderTextFieldStyle())
        .onReceive(
            NotificationCenter.default.publisher(for: UITextField.textDidEndEditingNotification),
            perform: textDidEndEditing
        )
    }
    
    func textDidEndEditing(_ notification: Notification) {
        guard shouldCommit else { return }
        shouldCommit = false
        
        let textField = notification.object as! UITextField
        textField.sendActions(for: .editingDidEndOnExit)
    }
}

まとめ

TextField の formatter を使った場合に、値が @State に反映されない場合があることがわかりました。そのため、UITextField を取り出し、sendActions(for: .editingDidEndOnExit) を実行することで、@State に値を反映させることができました。

あまり良い方法ではないと思いますが、どうしても困った場合、何かの参考になれば幸いです。