SwiftUIで同じ値を複数の変数で持つ場合の動きについて
いま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
とのやりとりは、@State
のString
変数、ということになります。
問題点
この話の問題点は、要は同じ値に対する2つの側面を別々の変数として管理することになり(Single Source of Truthに反しているので)値の同期や「どちらが正か」が難しくなる点です。
本質的に良さそうなのは、外部とやりとりする変数が、実態としてはこちらのコンピューティドプロパティになっていて、get
されるときに、String
の値をDouble
に変換して返し、set
されるときにはString
に変換してTextField
にセットする、というようなものです。これならSingle Source of Truthに反していません。
ですが(これまだ分かってないだけかも知れませんが)外部から指定されている変数である以上、こちらのコンピューティドプロパティにするというわけにもいきません。
その他の案
別の案として、そもそも外部には数値型ではなくてString
で公開するという手もあります。
ただ、これだと同じ変数を使ってStepper
と連動したりできないなど、数値のみ入力可能なTextField
というメリットが活きないというような問題があります。
このため仕方なしに@Binding
でDouble
を使って外部とやりとりします。
続きはまたあとで。
それで、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
ハンドラでは、このフラグが内部更新処理中なら相手を更新しない。
期待される処理順
期待される処理順の例としては以下のようになります。
-
TextField
が編集され、String
変数が更新される。 -
String
の.onChange
ハンドラが呼び出される。-
String
更新中=false
なので、「相手=Double
を更新する」ための以下の処理を実行する。 -
Double
更新中=true
、に設定する。 -
String
に合わせてDouble
を更新する。 -
Double
の.onChange
ハンドラが呼び出される。-
Double
更新中=true
なので何もせずDouble
の.onChange
ハンドラを終了する。
-
-
Double
更新中=false
、に設定する。 -
String
の.onChange
ハンドラを終了する。
-
実際の動き
これを実際にやってみると、.onChange
ハンドラ内で、相手を更新しても、相手の.onChange
ハンドラは、こちらの.onChange
ハンドラが終わるまでは呼び出されませんでした。逆に終わってから呼び出されてしまいます。つまり実行順が以下のようになります。
-
TextField
が編集され、String
変数が更新される。 -
String
の.onChange
ハンドラが呼び出される。-
String
更新中=false
なので、「相手=Double
を更新する」ための以下の処理を実行する。 -
Double
更新中=true
、に設定する。 -
String
に合わせてDouble
を更新する。 -
Double
更新中=false
、に設定する。 -
String
の.onChange
ハンドラを終了する。
-
-
Double
の.onChange
ハンドラが呼び出される。-
Double
更新中=false
なので、「相手=String
を更新する」ための以下の処理を実行する。 - …
-
この「更新中フラグによって、相手の更新をするかどうかを制御する」という仕組みは上記のようにうまく機能しませんでした。
(常に更新されてしまいます)
didSet
が使えるんじゃないかと思って試しましたが、これはダメでした。
@Binding
のdidSet
は、外で更新された場合に呼び出されません。
いま試している実装は、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
しかない気がします。
フラグを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
は更新されていって、そのつど、TestTextField
のinit
が呼ばれます。
init
の中ではフラグと日時を更新しています。
ですが、.onChange
のクロージャ内では、init
で更新された値ではなく、1世代前の値が使われています。
init
の実行前の段階で、.onChange
のクロージャのキャプチャが行われていて(?)、そのクロージャ内から見えるself
は、.onChange
が実行されるときのself
とは違う、ということでしょうか。
以下のような感じでフラグのアドレスも出力するようにしてみました。
let ptr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self.updateFlag).toOpaque())
print("&updateFlag 1: ", ptr.debugDescription)
本当を言うとself
のアドレスを出したいのだけれども、出し方が分かりません。フラグ部分はclass
なので出せます。self
はstruct
かつ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つのことが分かります。
- 毎回作り直されていること。アドレスが次々に変わっていきます。何かプールのようなもので使い回されていて、例えばプールが2個だからこういう交互の動作になっているのかとも思いましたがそうではないようです。
- 実際に、1世代前のアドレスを参照していること。
本当を言うとselfのアドレスを出したいのだけれども、出し方が分かりません。
これについては
で、Quinn "The Eskimo!"さんが
- 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.
というようなことを言っているので、出せないんでしょうね。
メモリにある必要がない、っていうのが私はよく分かってませんが。レジスタだけに存在するとか?
ちなみにupdateFlag
をclass
ではなくBool
にすると、1回目の+ボタンでテキストが4になりますが、そこでフラグがfalse
になって、以後、true
に戻らず、5や6にはなりません。doubleValue
だけがカウントアップされます。
class
が1世代前の値を参照しているのとは違う動きです。
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世代前ではなく、最初に値変更されたときのものがずっと設定されています。
どちらかというと、Bool
の「いったん設定されたらinit
が呼ばれても再初期化はされない」という動きのほうは分かります。@State
は初期化1回だけ、という話なので。
class
の「1世代前のアドレスを指す」は、なんだろう、よく分かりません。
疑似的なコードで書くと、
let newView = TestTextField()
oldView.onChangeClosure(newValue)
let allBindings = oldView.getAllBindings()
newView.setAllBindings(allBindings)
oldView.destroy()
みたいなことなんでしょうか。
init
のログとそのあとに続くログとを同じインスタンスのものと思い込んでいましたが、上のような処理、つまり新しいViewが作られたあと、古いViewでハンドラが実行されて各種変数の値が更新され、その値を新しいViewに引き継ぐ、と考えると、class
のほうのログの出方は分かります。
Bool
のほうも、もし上記のnewView.setAllBindings
が.onChange
を呼ばないのであれば、こういう動きになりそうという気がします。
そうなるとclass
にして、init
で初期化、という考え方はNGな気がします。
結局「バインディング変数が更新されることで実行されるinit
」で初期化した値は、通常変数だろうが@State
だろうが、@StateObject
だろうが、「バインディング変数が更新されることで実行される.onChange
」には渡りません。
実行順序はinit
→.onChange
なんですが。
理屈では@Stateの変数は1回しか初期化されないはずなので、仮にinitが呼ばれても都度trueにはならないと思うのですが、いま動かす限りでは、うまく動作しているように見えます。
うまく行かないケースがありますね。
false
になったままになってしまうことがある。
どうするかな…。
結局、当初の処理、すなわち循環が発生するような処理、に戻しました。
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値を更新しない、としました。
色々調べたけれども、なんのことはなかったです。