Open14

SwiftUIで同じ値を複数の変数で持つ場合の動きについて

kabeyakabeya

いまSwiftUIで、数値のみ入力可能なTextFieldを作っています。
(案外、そういう情報がない)

ことのおこり

外部とのやりとりに使う@Binding変数の型はDoubleとします。
(Decimalが良かったけど、このテキストフィールドと連動させて使いたいところのStepperに不具合があってDecimalが使えないので)

ただ、TextFieldの内部処理では、Stringで入力内容を持ってないと、色んな場面で困ります。

  • "0.301"というように入力していくとき、"0."まで打って"0"になったら困る。
  • 同じく"0.30"まで打って、"0.3"になったら困る。"0.30"の状態が維持されないと、"0.301"が打てない。
  • と言って、"0.3a"とかは打たせたくない。この場合は"0.3"になって欲しい。"0.30a"は"0.30"になって欲しい。

SwiftUIでは、TextFieldに入力されている文字列を取ってくる方法や、入力されたかどうかを検知する方法がBinding変数経由しかないので、つまりは内部で持つTextFieldとのやりとりは、@StateString変数、ということになります。

問題点

この話の問題点は、要は同じ値に対する2つの側面を別々の変数として管理することになり(Single Source of Truthに反しているので)値の同期や「どちらが正か」が難しくなる点です。

本質的に良さそうなのは、外部とやりとりする変数が、実態としてはこちらのコンピューティドプロパティになっていて、getされるときに、Stringの値をDoubleに変換して返し、setされるときにはStringに変換してTextFieldにセットする、というようなものです。これならSingle Source of Truthに反していません。

ですが(これまだ分かってないだけかも知れませんが)外部から指定されている変数である以上、こちらのコンピューティドプロパティにするというわけにもいきません。

その他の案

別の案として、そもそも外部には数値型ではなくてStringで公開するという手もあります。
ただ、これだと同じ変数を使ってStepperと連動したりできないなど、数値のみ入力可能なTextFieldというメリットが活きないというような問題があります。

このため仕方なしに@BindingDoubleを使って外部とやりとりします。

続きはまたあとで。

kabeyakabeya

それで、Stringの変数が更新されたらDoubleの変数を更新し、Doubleの変数が更新されたらStringの変数を更新する、というようなことをします。

TextField.onChange(of:perform:)というモディファイアがあります。of:には監視する変数を指定できるので、このモディファイアを2個使ってそれぞれの変数を監視して互いを更新します。

問題

想像つくと思いますが、String変数が変更される→Double変数を更新する→Double変数の更新によってString変数が更新される、というような再帰というか循環というか、そういうのが発生します。
String値」と「String値をDouble値に変換して、それをまたString値に戻したもの」が同じ場合は、新旧が同じなら更新処理を実行させないようにすることで発生させないようにできるので問題ないのですが、例えばString値が"0.0"だと、Double値に変換してまたString値に戻したときには"0"になってしまい問題が発生します。

最初に考えた回避方法

で、最初に考えた回避方法は以下のようなものでした。

  • String内部更新処理中フラグ、Double内部更新処理中フラグというようなものを用意する(@State変数として用意)
  • 相手を更新する直前に「内部更新処理中だよ」というようにフラグをセットする。相手の更新後にこのフラグをリセットする。
  • .onChangeハンドラでは、このフラグが内部更新処理中なら相手を更新しない。

期待される処理順

期待される処理順の例としては以下のようになります。

  1. TextFieldが編集され、String変数が更新される。
  2. String.onChangeハンドラが呼び出される。
    1. String更新中=falseなので、「相手=Doubleを更新する」ための以下の処理を実行する。
    2. Double更新中=true、に設定する。
    3. Stringに合わせてDoubleを更新する。
    4. Double.onChangeハンドラが呼び出される。
      1. Double更新中=trueなので何もせずDouble.onChangeハンドラを終了する。
    5. Double更新中=false、に設定する。
    6. String.onChangeハンドラを終了する。

実際の動き

これを実際にやってみると、.onChangeハンドラ内で、相手を更新しても、相手の.onChangeハンドラは、こちらの.onChangeハンドラが終わるまでは呼び出されませんでした。逆に終わってから呼び出されてしまいます。つまり実行順が以下のようになります。

  1. TextFieldが編集され、String変数が更新される。
  2. String.onChangeハンドラが呼び出される。
    1. String更新中=falseなので、「相手=Doubleを更新する」ための以下の処理を実行する。
    2. Double更新中=true、に設定する。
    3. Stringに合わせてDoubleを更新する。
    4. Double更新中=false、に設定する。
    5. String.onChangeハンドラを終了する。
  3. Double.onChangeハンドラが呼び出される。
    1. Double更新中=falseなので、「相手=Stringを更新する」ための以下の処理を実行する。

この「更新中フラグによって、相手の更新をするかどうかを制御する」という仕組みは上記のようにうまく機能しませんでした。
(常に更新されてしまいます)

kabeyakabeya

didSetが使えるんじゃないかと思って試しましたが、これはダメでした。
@BindingdidSetは、外で更新された場合に呼び出されません。

kabeyakabeya

いま試している実装は、String変数に反映する必要があるフラグ、Double変数に反映する必要があるフラグ、の2つを@State変数として持たせるという方法です。
初期値はどちらもtrueです。

String.onChangeハンドラでは、Double変数に反映する必要がなければ処理を行いません。
Double変数に反映する必要があれば、こんどは自分=String変数に反映する必要があるフラグをfalseに設定した上で、Double変数の更新を行います。

そして、フラグをtrueにする処理は書きません。

フラグはfalseにする処理しかないのですが、@Binding変数、この場合はDouble変数ですが、これを外側で変更するとinitが呼ばれて、フラグがtrueになります。

何かおかしい

理屈では@Stateの変数は1回しか初期化されないはずなので、仮にinitが呼ばれても都度trueにはならないと思うのですが、いま動かす限りでは、うまく動作しているように見えます。

ですが小さいコードを書くとうまくいかない。
falseになったままになってしまいます。

仮にtrueにする処理を書くとするとどこか

もしこれがおかしいとして、フラグをtrueに戻す処理を追加するとすればどこかというと、やはりinitしかない気がします。

kabeyakabeya

フラグをBoolで用意しているから@Stateにする必要がある(そうでないとvar bodyの中で更新できない)のであって、classにしたら毎度初期化できたうえに、var bodyのなかで更新できるのではないか、ということで試してみたところ、予想外の動きをしました。

フラグをclassを用いた場合の実装

struct TestTextField: View {
    class UpdateFlag {
        var needToUpdateText: Bool = true
        var updatedTime: Date = Date.now
    }
    @State var textValue: String = "0"
    @Binding var doubleValue: Double
    let updateFlag = UpdateFlag()
    
    init(doubleValue: Binding<Double>) {
        self._doubleValue = doubleValue
        self._textValue = State<String>(initialValue: doubleValue.wrappedValue.formatted())
        print("--- init ---")
        self.updateFlag.needToUpdateText = true
        self.updateFlag.updatedTime = Date.now
        print("needToUpdateText 1: \(self.updateFlag.needToUpdateText)")
        print("updatedTime 1: \(self.updateFlag.updatedTime)")
    }
    
    var body: some View {
        VStack {
            TextField("textfield title", text: $textValue)
                .onChange(of: doubleValue, perform: { newDouble in
                    print(".onChange of:doubleValue -> \(newDouble)")
                    print("needToUpdateText 2: \(self.updateFlag.needToUpdateText)")
                    print("updatedTime 2: \(self.updateFlag.updatedTime)")
                    if !self.updateFlag.needToUpdateText {
                        return
                    }
                    self.textValue = newDouble.formatted()
                })
                .onChange(of: textValue, perform: { newText in
                    print(".onChange of:textValue -> \(newText)")
                    self.updateFlag.needToUpdateText = false
                    self.updateFlag.updatedTime = Date.now
                    print("needToUpdateText 3: \(self.updateFlag.needToUpdateText)")
                    print("updatedTime 3: \(self.updateFlag.updatedTime)")
                    if let newDouble = Double(newText) {
                        self.doubleValue = newDouble
                    }
                })
            Stepper("stepper title", value: $doubleValue)
        }
    }
}

テキストフィールドとステッパーがある画面です。

実行した結果

ステッパーを押すと値が変わります。

テキストの初期値が3ですが、+ボタンを1回押すと4になり、もう1度押しても変わらず、再度押すと6になります。
2回に1回更新されるような動きになります。

その際のログは以下になります。

--- init ---  ← 起動時点。
needToUpdateText 1: true
updatedTime 1: 2023-05-01 18:40:58 +0000 ※1
--- init --- ← +ボタンを押す。
needToUpdateText 1: true  ← initで初期化される。
updatedTime 1: 2023-05-01 18:41:02 +0000 ※2
.onChange of:doubleValue -> 4.0 ← 初期値が3なので4になる。
needToUpdateText 2: true
updatedTime 2: 2023-05-01 18:40:58 +0000 ← ※2ではなく※1が入っている!
.onChange of:textValue -> 4
needToUpdateText 3: false
updatedTime 3: 2023-05-01 18:41:02 +0000
--- init --- ← もう一度プラスボタンを押す
needToUpdateText 1: true
updatedTime 1: 2023-05-01 18:41:07 +0000 ※3
.onChange of:doubleValue -> 5.0
needToUpdateText 2: false
updatedTime 2: 2023-05-01 18:41:02 +0000 ← ※3ではなく※2が入っている!
--- init ---  ← もう一度プラスボタンを押す
needToUpdateText 1: true
updatedTime 1: 2023-05-01 18:41:10 +0000 ※4
.onChange of:doubleValue -> 6.0
needToUpdateText 2: true
updatedTime 2: 2023-05-01 18:41:07 +0000 ← ※4ではなく※3が入っている!
.onChange of:textValue -> 6
needToUpdateText 3: false
updatedTime 3: 2023-05-01 18:41:10 +0000 

フラグだけだと、いつ設定したのか分からないので、日時も持たせました。
+ボタンを押すたびにdoubleValueは更新されていって、そのつど、TestTextFieldinitが呼ばれます。
initの中ではフラグと日時を更新しています。
ですが、.onChangeのクロージャ内では、initで更新された値ではなく、1世代前の値が使われています。

initの実行前の段階で、.onChangeのクロージャのキャプチャが行われていて(?)、そのクロージャ内から見えるselfは、.onChangeが実行されるときのselfとは違う、ということでしょうか。

kabeyakabeya

以下のような感じでフラグのアドレスも出力するようにしてみました。

  let ptr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self.updateFlag).toOpaque())
  print("&updateFlag 1: ", ptr.debugDescription)

本当を言うとselfのアドレスを出したいのだけれども、出し方が分かりません。フラグ部分はclassなので出せます。selfstructかつvar bodyの中ではimmutableなので、出そうとするとエラーになってしまいます。

実行結果

--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 19:57:16 +0000
&updateFlag 1:  0x000060000370cb80
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 19:57:19 +0000
&updateFlag 1:  0x000060000371cf60
.onChange of:doubleValue -> 4.0
needToUpdateText 2: true
updatedTime 2: 2023-05-01 19:57:16 +0000
&updateFlag 2:  0x000060000370cb80
.onChange of:textValue -> 4
needToUpdateText 3: false
updatedTime 3: 2023-05-01 19:57:19 +0000
&updateFlag 3:  0x000060000371cf60
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 19:57:23 +0000
&updateFlag 1:  0x000060000371d260
.onChange of:doubleValue -> 5.0
needToUpdateText 2: false
updatedTime 2: 2023-05-01 19:57:19 +0000
&updateFlag 2:  0x000060000371cf60
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 19:57:28 +0000
&updateFlag 1:  0x000060000371d340
.onChange of:doubleValue -> 6.0
needToUpdateText 2: true
updatedTime 2: 2023-05-01 19:57:23 +0000
&updateFlag 2:  0x000060000371d260
.onChange of:textValue -> 6
needToUpdateText 3: false
updatedTime 3: 2023-05-01 19:57:28 +0000
&updateFlag 3:  0x000060000371d340

2つのことが分かります。

  1. 毎回作り直されていること。アドレスが次々に変わっていきます。何かプールのようなもので使い回されていて、例えばプールが2個だからこういう交互の動作になっているのかとも思いましたがそうではないようです。
  2. 実際に、1世代前のアドレスを参照していること。
kabeyakabeya

本当を言うとselfのアドレスを出したいのだけれども、出し方が分かりません。

これについては

https://forums.swift.org/t/memory-address-of-value-types-and-reference-types/6637

で、Quinn "The Eskimo!"さんが

  1. Is it possible to get the memory address of immutable value type (declared as let)

No, because such values don’t necessarily exist in memory.

というようなことを言っているので、出せないんでしょうね。
メモリにある必要がない、っていうのが私はよく分かってませんが。レジスタだけに存在するとか?

kabeyakabeya

ちなみにupdateFlagclassではなくBoolにすると、1回目の+ボタンでテキストが4になりますが、そこでフラグがfalseになって、以後、trueに戻らず、5や6にはなりません。doubleValueだけがカウントアップされます。

classが1世代前の値を参照しているのとは違う動きです。

kabeyakabeya

classではなくBoolにしたときの場合にも更新日時を出すようにしました。

実行結果

--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 20:28:54 +0000 ※1
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 20:28:56 +0000 ※2
.onChange of:doubleValue -> 4.0
needToUpdateText 2: true
updatedTime 2: 2023-05-01 20:28:54 +0000 ←※1を指してる
.onChange of:textValue -> 4
needToUpdateText 3: false
updatedTime 3: 2023-05-01 20:28:56 +0000 ※3
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 20:28:58 +0000 ※4
.onChange of:doubleValue -> 5.0
needToUpdateText 2: false
updatedTime 2: 2023-05-01 20:28:56 +0000 ←※2というか、おそらく※3を指してる
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 20:29:01 +0000
.onChange of:doubleValue -> 6.0
needToUpdateText 2: false
updatedTime 2: 2023-05-01 20:28:56 +0000 ←※2というか、おそらく※3を指してる
--- init ---
needToUpdateText 1: true
updatedTime 1: 2023-05-01 20:29:04 +0000
.onChange of:doubleValue -> 7.0
needToUpdateText 2: false
updatedTime 2: 2023-05-01 20:28:56 +0000 ←※2というか、おそらく※3を指してる

更新日時は1世代前ではなく、最初に値変更されたときのものがずっと設定されています。

kabeyakabeya

どちらかというと、Boolの「いったん設定されたらinitが呼ばれても再初期化はされない」という動きのほうは分かります。@Stateは初期化1回だけ、という話なので。
classの「1世代前のアドレスを指す」は、なんだろう、よく分かりません。

kabeyakabeya

疑似的なコードで書くと、

let newView = TestTextField()
oldView.onChangeClosure(newValue)
let allBindings = oldView.getAllBindings()
newView.setAllBindings(allBindings)
oldView.destroy()

みたいなことなんでしょうか。

initのログとそのあとに続くログとを同じインスタンスのものと思い込んでいましたが、上のような処理、つまり新しいViewが作られたあと、古いViewでハンドラが実行されて各種変数の値が更新され、その値を新しいViewに引き継ぐ、と考えると、classのほうのログの出方は分かります。
Boolのほうも、もし上記のnewView.setAllBindings.onChangeを呼ばないのであれば、こういう動きになりそうという気がします。

kabeyakabeya

そうなるとclassにして、initで初期化、という考え方はNGな気がします。

結局「バインディング変数が更新されることで実行されるinit」で初期化した値は、通常変数だろうが@Stateだろうが、@StateObjectだろうが、「バインディング変数が更新されることで実行される.onChange」には渡りません。

実行順序はinit.onChangeなんですが。

kabeyakabeya

理屈では@Stateの変数は1回しか初期化されないはずなので、仮にinitが呼ばれても都度trueにはならないと思うのですが、いま動かす限りでは、うまく動作しているように見えます。

うまく行かないケースがありますね。
falseになったままになってしまうことがある。

どうするかな…。

kabeyakabeya

結局、当初の処理、すなわち循環が発生するような処理、に戻しました。

String変数が変更される→Double変数を更新する→Double変数の更新によってString変数が更新される、というような再帰というか循環というか、そういうのが発生します。
例えばString値が"0.0"だと、Double値に変換してまたString値に戻したときには"0"になってしまい

結局、この連鎖というか再帰というか循環というかそういうのが発生しなければよい、ということで、どうにかしてそれを止めればOK、と考え直しました。

String値は上記のように"0.0"≠"0"のように、意味合い的には同じなのに値には複数のバリエーションが存在します。一方、Double値は同じ値に対してバリエーションがなく[1]、意味合いが決まれば値(表現)は一意に決まります。なので、String変数の変更発生時に「変更前のString変数をDouble値に変換したもの」と「変更後のString変数をDouble値に変換したもの」が異なるときだけDouble値を更新し、2つが同じなら文字列としては違えど意味合いは同じだからDouble値を更新しない、としました。

色々調べたけれども、なんのことはなかったです。

脚注
  1. 別のスクラップで書きましたが、Swift?NSNumber?は-0があるので、0に対して2つの表現があることになります。なのでそこだけは少し特別な扱いをする必要があります。 ↩︎