ひとしれずひっそり

主にソフトに関することをメモしていきます。過程をそのまま書いていたりするので間違いが含まれます。鵜呑みしない様に。

TextFieldでOptional値を扱う場合の改善

前に書いたTextFieldでOptional値を扱う場合にView内の@State属性を付けたメンバー変数に一旦渡して処理する方法を試した。
メンバー変数に値を渡すタイミングとしてonAppear()を使っていた。

TextField - ひとしれずひっそり

onAppear()は描画時に1回呼ばれるだけなので、外部で値が変更された場合に適用されない場合がある。

例としてこの様なモデルを用意した。
timeはOptionalでnilを与えることができる。

import Foundation
import SwiftUI

class Model: ObservableObject {
    init(name: String, time: Double?) {
        self.name = name
        self.time = time
    }
    var name: String
    var time: Double?
}

var models: [Model] = [
    Model(name: "A", time: 1.0),
    Model(name: "B", time: 2.0),
]

Viewとして、ボタンAとBを準備。
それぞれを押すとmodels[0]とmodels[1]の値を切り替えて編集できる。
Aを押してからBを押すと2.0が表示されるはずだがAを押した時に表示された1.0のままになっている。
起動時に1回onAppear()が呼び出されるが、Bを押した時にはonAppear()が呼び出されないので変わらない。

TextFieldでoptionalを扱う場合について · GitHub


Modelに一時変数を用意するのが正しいやり方ではないかと思う。

こんな感じ

class Model: ObservableObject {

.
.

    private var _timeStr: String? = nil
    var timeStr: String {
        get {
            if let _timeStr {
                return _timeStr
            } else {
                if let t = time {
                    _timeStr = String(t)
                } else {
                    _timeStr = ""
                }
            }
            return _timeStr!
        }
        set {
            _timeStr = newValue
        }
    }

ただ、このままだとtimeに反映されないので、編集が終わった段階で反映させる様にしなければならない。
ここではsave()メソッドで行うこととする。

    public func save() {
        if let _timeStr,
           let n = Double(_timeStr) {
            time = n
            debugPrint("save \(name)")
        }
        _timeStr = nil
    }

これで試すが、Bを選んで値を変更してリターンキーを押すが、Aのモデルの方のsave()が呼ばれてしまう。
これではBのモデルを変更できない。なんで?

"save A"
"save A"

TextFieldでoptionalを扱う場合について · GitHub


元に戻って@State属性を付けたメンバー変数に渡す方法でなんとかしてみる。

onAppear()の代わりのタイミングを作るためにTimerを使う。
modelが変化したらタイマーで遅らせて読み直す。

struct ModelView: View {
    @ObservedObject var model: Model
    @State var timeStr: String = ""
    
    @State var timer = Timer.publish(every: 0.1, on: .current, in: .common).autoconnect()

    var body: some View {
        HStack {
            Text(model.name)
            TextField("", text: $timeStr) { editing in
                if editing == false,
                   let t = Double(timeStr) {
                    debugPrint("save \(model.name)")
                    model.time = t
                }
            }
        }
        .onAppear() {
            stopTimer()
            reloadTime()
        }
        .onChange(of: model) { newValue in
            startTimer()
        }
        .onReceive(timer) { timer in
            stopTimer()
            reloadTime()
        }

    }
    
    private func reloadTime() {
        if let t = model.time {
            timeStr = String(t)
        } else {
            timeStr = ""
        }
    }
    
    private func startTimer() {
        timer = self.timer.upstream.autoconnect()
    }
    
    private func stopTimer() {
        timer.upstream.connect().cancel()
    }
}

変化したか比べるためにModelはEquatableをアダプトする必要がある。

class Model: ObservableObject, Equatable {
.
.    
    static func == (lhs: Model, rhs: Model) -> Bool {
        return lhs.name == rhs.name && lhs.time == rhs.time
    }
}

これで試してみる。
あれっ、こちらも変わらないぞ。

"save A"
"save A"

TextFieldでoptionalを扱う場合について · GitHub


原因はTextFieldのonEditingChangedに渡しているブロックだった。
ブロックを作成した時のモデルに紐づくのでいつまでもAのモデルを呼び出してしまう。

            TextField("", text: $timeStr) { editing in
                if editing == false,
                   let t = Double(timeStr) {
                    debugPrint("save \(model.name)")
                    model.time = t
                }
            }

onEditingChangedブロックではフラグを変えるだけにする。

    @State var isEditing = false
    
    var body: some View {
        HStack {
            Text(model.name)
            TextField("", text: $timeStr) { editing in
                isEditing = editing
            }
        }

フラグの変化をみて処理する様にする。

        .onChange(of: isEditing, perform: { editing in
            if editing == false,
               let t = Double(timeStr) {
                debugPrint("save \(model.name)")
                model.time = t
            }
        })

こんどはBのモデルが呼び出されている。

"save A"
"save B"

TextFieldでoptionalを扱う場合について · GitHub


Modelで処理する方も同じはず。

struct ModelView: View {
    @ObservedObject var model: Model
    
    @State var isEditing = false

    var body: some View {
        HStack {
            Text(model.name)
            TextField("", text: $model.timeStr) { editing in
                isEditing = editing
            }
        }
        .onChange(of: isEditing, perform: { editing in
            if editing == false {
               model.save()
            }
        })
    }
}

こっちもうまくいった。

"save A"
"save B"

TextFieldでoptionalを扱う場合について · GitHub

まとめ

  • モデルで処理するのが多分正しい方法。

    • 個人的には _timeStr という感じで変数を使うのが気に入らないのだが…(といいつつobjcでは散々使っていた)  
  • onEditingChanged ブロックはフラグ処理だけにする。

  • ViewでもTimer使えばなんとかなる。
    • Viewが複雑(設定項目が増える)になってくるるとこっちの方がいいかなというのが現在の感想。

追記

モデルに編集用のプロパティを持たせた場合に、そこにUndo/Redoの仕組みをいれると文字列の場合Undo/Redoの単位が一文字になってしまう。
そのため、編集が終わったところを区切りとしたいのにできない。
Groupingを使う方法もあると思うが異常終了してしまいうまくいかなかった。

View内の変数に展開してTimerで処理するのが今のところ最善策。


参考:

stackoverflow.com