問題
アラーム機能のついた時計を作りたいとします。アラームは時計の他にキッチンタイマーなどにも使用できるよう時計から分離した部品とします。
参照の方向はView
→Clock(時計ロジック)
→Alarm(アラームロジック)
になります。
以下の実装ではButtonを押してもTextが変化しません。SwiftUIはclock.alarm.isOn
の変化を検知できないためViewを更新しないからです。
struct ContentView: View {
@ObservedObject private var clock = Clock()
var body: some View {
VStack {
Text(clock.isAlarmOn ? "On" : "Off")
Button("Toggle Alarm") {
if clock.isAlarmOn {
clock.stopAlarm()
} else {
clock.ringAlarm()
}
}
}
}
}
class Clock: ObservableObject {
private let alarm = Alarm() // alarmはprivateにして、以下にView用のインターフェイスを作る。
var isAlarmOn: Bool { // alarm.isOnが変化してもSwiftUIは検知しない。
alarm.isOn
}
func ringAlarm() {
alarm.ring()
}
func stopAlarm() {
alarm.stop()
}
// 以下、時計本来の機能...
}
class Alarm {
private(set) var isOn = false //ここの変化をViewに反映したい。
func ring() {
isOn = true
}
func stop() {
isOn = false
}
}
同期的な場合
ObservableObject
プロトコルで宣言されているobjectWillChange
のsend()
メソッドを当該プロトコルに準拠しているClock
の中の適切な位置で呼ぶことで、SwiftUIに変化を通知出来ます。
class Clock: ObservableObject {
private let alarm = Alarm()
var isAlarmOn: Bool {
alarm.isOn
}
func ringAlarm() {
+ objectWillChange.send() // 直後、状態に変化が起こることをSwiftUIに伝える。
alarm.ring()
}
func stopAlarm() {
+ objectWillChange.send() // 直後、状態に変化が起こることをSwiftUIに伝える。
alarm.stop()
}
}
これで簡単に解決しました。SwiftUIは状態が変化すると通知されると、clock.isAlarmOnの値を確認し、実際に変化があると新しい値でViewを再構築します。
しかしAlarmのメソッドを非同期にしたい場合はどのようになるでしょうか?
非同期的な場合
class Alarm {
private(set) var isOn = false
func ring() {
+ Task {
+ sleep(1) // 1秒待機
isOn = true
+ }
}
func stop() {
+ Task {
+ sleep(1) // 1秒待機
isOn = false
+ }
}
}
上記の場合、Clockでalarmのメソッドを呼ぶ前にobjectWillChange.send()
を呼んでも、意図した通りにはViewに反映されません。SwiftUIは「直後に変化が起こる」と伝えられ、clock.isAlarmOnの値を確認しますが、その時点では実際には変化していないためです。
ここでの問題は、変化が起こるという通知と実際に変化が起こる時点に差があることです。ではどのようにすれば良いでしょうか?例えば以下のようにAlarm
もObservableObject
にしてみましょうか。
- class Alarm {
+ class Alarm: ObservableObject { // ObservableObjectに準拠...?
private(set) var isOn = false
+ {
+ willSet {
+ objectWillChange.send() // isOnの変化直前に変更を通知...?
+ }
+ }
func ring() {
Task {
sleep(1)
isOn = true
}
}
func stop() {
Task {
sleep(1)
isOn = false
}
}
}
上記の実装では上手く動きません。SwiftUIが監視するのはあくまでもViewが持つObservedObjectであるclockプロパティであり、clock.alarm.isOnは監視の対象外です。
以下のようにしましょう。
class Alarm {
private(set) var isOn = false
+ private let objectWillChange: ObservableObjectPublisher
+ init(objectWillChange: ObservableObjectPublisher) { // 使用者からObservableObjectPublisherを受け取る。
+ self.objectWillChange = objectWillChange
+ }
func ring() {
Task {
sleep(1)
+ objectWillChange.send()
isOn = true
}
}
func stop() {
Task {
sleep(1)
+ objectWillChange.send()
isOn = false
}
}
}
class Clock: ObservableObject {
- private let alarm = Alarm()
+ lazy var alarm = Alarm(objectWillChange: self.objectWillChange) // selfのプロパティを渡す必要があるので、`lazy var`にする。
var isAlarmOn: Bool {
alarm.isOn
}
func ringAlarm() {
alarm.ring()
}
func stopAlarm() {
alarm.stop()
}
}
これで意図した通りに動作します。
備考
今回の例ではAlarmはPresenterよりもModelらしい命名なので、objectWillChange
といったView側の事情を知っているのは責務の分離が出来ておらず望ましくないように見えますね。このAlarmはAlarmPresenterだと思っていただけると🙇
また本来Viewに影響が及ぶプロパティの値の変更はmain threadから行う必要がありますが簡単のために省略しています。
あと、iOS17以降はObservation Macro
が使えるから、もっと簡単に使えるはず。