LoginSignup
1
2

ネストしたクラスの状態変化をViewに伝える。

Last updated at Posted at 2024-02-03

問題

アラーム機能のついた時計を作りたいとします。アラームは時計の他にキッチンタイマーなどにも使用できるよう時計から分離した部品とします。
参照の方向はViewClock(時計ロジック)Alarm(アラームロジック)になります。

以下の実装ではButtonを押してもTextが変化しません。SwiftUIはclock.alarm.isOnの変化を検知できないためViewを更新しないからです。

View.swift
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()
                }
            }
        }
    }
}
Clock.swift
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()
    }
    // 以下、時計本来の機能... 
}
Alarm.swift
class Alarm {
    private(set) var isOn = false //ここの変化をViewに反映したい。

    func ring() {
        isOn = true
    }

    func stop() {
        isOn = false
    }
}

同期的な場合

ObservableObjectプロトコルで宣言されているobjectWillChangesend()メソッドを当該プロトコルに準拠している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の値を確認しますが、その時点では実際には変化していないためです。

ここでの問題は、変化が起こるという通知と実際に変化が起こる時点に差があることです。ではどのようにすれば良いでしょうか?例えば以下のようにAlarmObservableObjectにしてみましょうか。

- 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が使えるから、もっと簡単に使えるはず。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2