入力値に8%加算して返してくれるUIパーツを作ってみたい

Swift
入力値に8%加算して値を返してくれるUIパーツを作ってみたい

やりたいこと

テキストボックスに入力された値に対して、何か処理をすることってよくある話だと思います。例えば、正の整数のみを受け付けたり、メールアドレスかどうかの判定をしたり。
そして、こういう時は大抵入力が誤っていることを何らかの形でユーザに伝えると思います。

そこで、値の入力を受け付けや、その値の処理、エラーメッセージの発出などを行うUIをパーツとして用意しておいたら使い回しができて良かろうと思った次第です。

似たようなことを都度書くのがやや億劫になったというのが正直なところです。

段取り

今回は入力された値に対して8%税込して返してくれるUIを作ってみようと思います。

数値が入力されているかリアルタイムに判定し、数値として判定されたら8%加算して表示、数値以外と判定されたらエラーメッセージを返すイメージです。

こんなぼんやりしたイメージで良いのかどうか若干あやしいですが、少しずつ行ってみましょう。

ざっくりとした流れはこのような感じでしょうか。細かいパーツを作って組み合わせていく方式です。

  • リアルタイムで入力値を受け取るテキストフィールドを作る
  • 消費税計算をするクラスを作る(無精して入力値の判定も入れた)
  • リアルタイムで入力値を受け取るテキストフィールドとエラーメッセージの表示ラベルを持つUIのセットを作る

良い子のみんなは消費税計算をするクラスと入力値判定のクラスを分けよう!役割分担は大切です。
それと、言い忘れていましたが、もちろんSwiftUIです。

作ってみる

リアルタイムで入力値を受け取るテキストフィールドを作る

このテキストフィールドの役割

ここで作るのはテキストを一文字ずつ入力するたびに任意の処理を呼び出すテキストフィールドです。
このテキストフィールドはそのまま使うのではなく、具体的な処理を規定したUIとして使うためのベースUIとして作ります。

今回は消費税計算ですが、例えば先ほど示した例のように入力値を数値だけに限定したり、入力桁数を制限したり、メールアドレスのみの入力を促したりなど、そういった制限をさせるためのベースUIとして使い回しできるようにしたいのです。

「任意の処理」はこのテキストフィールドで指定するのではなく、具体的な処理を規定したUIを作るときに指定します。

作っていく

では実際に、リアルタイムで入力値を受け取るテキストフィールドを作ってみようと思います。

UITextFieldをSwiftUIで使えるようにします。UITextFieldのデリゲートを使いたいためです。

UITextFieldをSwiftUIで使えるようにするには、UIViewRepresentableを継承した構造体を作ります。今回はUIView側とSwiftUI側で双方向にやり取りをしたいため、作るべきメソッドは次の3つです。

  • makeCoordinator
  • makeUIView
  • updateView
struct RealtimeTextField: UIViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> RealtimeTextFieldCoordinator {
        RealtimeTextFieldCoordinator(self)
    }

    func makeUIView(context: Context) -> UITextField {
        let textView = UITextField()
        textView.text = text
        return textView
    }

    func updateUIView(_ textView: UITextField, context: Context) {
        textView.text = text
    }
}

また、文字が入力される都度イベントを呼び出すために、UITextFieldDelegateを実装しておきます。

func makeUIView(context: Context) -> UITextField {
     let textView = UITextField()
     textView.delegate = context.coordinator //delegate
     textView.text = text
     return textView
}
class RealtimeTextFieldCoordinator : NSObject, UITextFieldDelegate {
    var realtimeTextField: RealtimeTextField
    var onEditingChanged: (Bool) -> Void

    init(_ realtimeTextField: RealtimeTextField, onEditingChanged: @escaping (Bool) -> Void = {_ in}) {
        self.realtimeTextField = realtimeTextField
        self.onEditingChanged = onEditingChanged
    }

    func textFieldDidChangeSelection(_ textField: UITextField) {
        self.realtimeTextField.text = textField.text ?? "0"
        onEditingChanged(true)
    }

    func textFieldDidBeginEditing(_ textField: UITextField) {
        onEditingChanged(true)
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
        onEditingChanged(false)
    }
}

このクラスはextensionでRealtimeTextFieldに内包しておいても良いかもです。

このとき呼び出すCoordinateのイニシャライザが変わったので、呼び出し方も変えておきます。
UITextFieldをSwiftUIでラップするときのイニシャライザも変わります。

struct RealtimeTextField: UIViewRepresentable {
    (略)
    var onEditingChanged: (Bool) -> Void

    init(text: Binding<String>, onEditingChanged: @escaping (Bool) -> Void = {_ in}) {
      self._text = text
      self.onEditingChanged = onEditingChanged
    }

    func makeCoordinator() -> RealtimeTextFieldCoordinator {
      RealtimeTextFieldCoordinator(self, onEditingChanged: onEditingChanged)
    }
    (略)

グニョグニョッと変化していきますが、落ち着いて順次対応していきましょう!

消費税計算をするクラスを作る

この辺はザザッと進めましょう。

無精をして入力値の判定処理も含めてしまっていますが、分離して別クラスにしておいたほうが後々余計な苦労をしなくて済むと思います。

軽減税率のみしか考慮していませんが、実際は10%課税や非課税不課税がありますし、場合によっては整数以外の数値にも対応しなければならないかもしれません。ここは要件に合わせて工夫をしていただければ幸いです。

class CalcTax: NSObject  {
    var errorMessage:String = ""
    var includedTax:String = "0"

    let RATE:Double = 0.08

    private func addTax(_ value:Int) -> Int {
        return Int(floor(Double(value) * (1.0 + RATE)))
    }

    func checkPositiveInteger(inputValue:String) -> Bool {
        if let value = Int(inputValue) {
            if value <= 0 {
                includedTax = "0"
                errorMessage = "正の整数を入力してください"
                return false
            }
            else {
                includedTax = String(addTax(value))
                errorMessage = ""
                return true
            }
        }
        else {
            includedTax = "0"
            errorMessage = "正の整数を入力してください"
            return false
        }
    }
}

値を保持していなくても支障がないところがミソだったりします。

余談ですが、入力値判定の処理中で税込金額を返すのはあまりよろしくないですね。このままでは、消費税計算を受け持つクラスと入力値判定をするクラスが分離し辛いです。

リアルタイムで入力値を受け取るテキストフィールドとエラーメッセージの表示ラベルを持つUIのセットを作る

さていよいよ本丸です。
これまで作ってきたパーツを組み合わせて、いくつかのUIがまとまったUI(グループ)を作ります。

テキストフィールドへの入力値と、それに対応したエラーメッセージと税込金額を扱えるよう変数を用意しておきます。

struct TaxText: View {
    @State var inputValue:String = ""
    @State var errorMessage:String = ""
    @Binding var includedTax:String

    // 消費税計算をするクラス
    var calcTax:CalcTax = CalcTax()

    var body: some View {
        VStack {
            // 入力値を受けるテキストフィールド(さっき作ったもの)
            RealtimeTextField(text: $inputValue, onEditingChanged: self.update)

            // エラー判定結果を表示するもの
            if calcTax.checkPositiveInteger(inputValue:inputValue) {
                Text(calcTax.errorMessage)
            }
            else {
                Text(calcTax.errorMessage)
            }
        }
    }

    func update(changed: Bool) {
        let _ = self.calcTax.checkPositiveInteger(inputValue:self.inputValue)
        self.errorMessage = self.calcTax.errorMessage
        self.includedTax = self.calcTax.includedTax
    }
}

入力値を受けるテキストフィールドに一文字入力されるたびに呼び出されるものを、ここで作ります。update(changed: Bool)ですね。これをRealtimeTextFieldの引数に渡しておくことで、1文字ずつ入力する都度呼び出されるようになります。

StateBindingはどのUI(Struct)が管理するのかをイメージしながら決めていくと良いような気がします。このイメージで大体当たりますがあくまでも感覚的なものなので、実際はちゃんと考えて決めてくださいね。

使ってみる

ここまで作っておくと、あとは使うだけです。

@State var includedTaxValue:String = ""

var body: some View {
    VStack {
        TaxText(includedTax: $includedTaxValue)
        Text(includedTaxValue)
    }
}
正の整数を入力したとき
正の整数以外のものを入力したとき

使うときにはエラーメッセージの表示がどうのこうの考えなくてもいいです。適宜表示してくれます。
ただ税込金額が返される(入力値が意図したものでない時はゼロが返ってくる)ようにしているので、それを使うことだけに集中できます。

もし、エラーメッセージの表示方法や、デザイン的なことを考えたければ、使う側でなく、前段のUIパーツを作る際に手を加えておいてください。

まとめ

入力値をチェックして、正の整数が入力されたら8%加算した税込金額を表示するUIパーツを作ってみました。かなり自由度が高い状態で作ったので、いかようにも応用と工夫が利くと思います。

デザイン周りについては、Modifierで指定できるようにしておくと、もっと使いやすいですね。