概要
iOS14以降でしか使えない@StateObjectをiOS13でも使えるようにするため同じ挙動をするPropertyWrapperを作成した。
コード
import Combine
import SwiftUI
@available(iOS 13.0, *)
@propertyWrapper
public struct StateObservedObject<T: ObservableObject>: DynamicProperty {
@State @Lazy private var object: T
@ObservedObject private var updater = StateObservedObjectUpdater()
@dynamicMemberLookup public struct Wrapper {
let value: T
let update: () -> Void
public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Subject>) -> Binding<Subject> {
.init(
get: { value[keyPath: keyPath] },
set: {
value[keyPath: keyPath] = $0
update()
}
)
}
}
public var projectedValue: Wrapper {
.init(value: object, update: _updater.wrappedValue.objectWillChange.send)
}
public var wrappedValue: T {
get {
object
}
set {
object = newValue
}
}
public init(wrappedValue: @autoclosure @escaping () -> T) {
self._object = State(wrappedValue: Lazy(wrappedValue))
}
// NOTE: - DynamicPropertyのViewが更新される直前に呼ばれるメソッド
public mutating func update() {
// Stateはupdateの中で直前のインスタンスに置き換えているので、置き換えた後(つまり一番最初に作られれたインスタンス)の中のupdaterのみを置き換え
_object.wrappedValue.update = _updater.wrappedValue.objectWillChange.send
}
}
@available(iOS 13.0, *)
extension StateObservedObject {
@propertyWrapper
private class Lazy {
let lazyValue: () -> T
var cached: T?
var update: (() -> Void)?
private var cancellableSet: Set<AnyCancellable> = []
init(_ value: @escaping () -> T) {
lazyValue = value
}
var wrappedValue: T {
get {
if let cached = cached {
return cached
}
cached = lazyValue()
cached?
.objectWillChange
.sink { [weak self] _ in
self?.update?()
}
.store(in: &cancellableSet)
return cached!
}
set {
cached = newValue
}
}
}
}
@available(iOS 13.0, *)
private class StateObservedObjectUpdater: ObservableObject {
}
解説
まずView外部の値を更新するものなので、DynamicProperty (Viewの外部プロパティを更新する格納変数)に適応させます。
DynamicPropertyはViewのBodyの中身を再計算するより前に値が与えられる。View.bodyの再生成される直前にDynamicProperty.update()が呼ばれるので、Stateなどはここで前回のインスタンスを再代入している。
今回内部に値を保持するためにStateで値を持ち、Viewの更新のためにObservedObjectでupdate用のObservableObjectを保持した。
public struct StateObservedObject<T: ObservableObject>: DynamicProperty {
@State @Lazy private var object: T // このStateが前回の値を保持してくれる
@ObservedObject private var updater = StateObservedObjectUpdater() // updater.objectWillChange.send()することでViewを更新する。
objectはLazyクラスをvalueとしたStateとして持ち、クロージャで渡すことで余計な評価をしないようにしている。StateObservedObjectのinitはViewが更新されるたびに呼ばれ、その度に新しいLazyクラスをvalueとしたStateが生成されるが、実際にはStateのupdate()関数内で破棄されているので、初回に生成されたもの以外は使用されない。
また、画面更新用のupdaterは都度更新されるので、State内のインスタンスが置き換わった後に内部のupdateクロージャを置き換えるようにしている。
public init(wrappedValue: @autoclosure @escaping () -> T) {
self._object = State(wrappedValue: Lazy(wrappedValue)) // <- 初回以外のここで生成されたインスタンスは下記のupdate()時には破棄されている。(正確にはState.update()の中で)
}
// NOTE: - DynamicPropertyのViewが更新される直前に呼ばれるメソッド
public mutating func update() {
// Stateはupdateの中で直前のインスタンスに置き換えているので、置き換えた後(つまり一番最初に作られれたインスタンス)の中のupdaterのみを置き換え
_object.wrappedValue.update = _updater.wrappedValue.objectWillChange.send
}
またStateObservedObjectのprojectedValueはStateやObservedObjectと(おそらく)同じように、KeyPathとDynamicMemberLookupを使用し、Bindingとして公開することで参照を子Viewに渡すことできるようになっている。
@dynamicMemberLookup public struct Wrapper {
let value: T
let update: () -> Void
public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Subject>) -> Binding<Subject> {
.init(
get: { value[keyPath: keyPath] },
set: {
value[keyPath: keyPath] = $0
update()
}
)
}
}
public var projectedValue: Wrapper {
.init(value: object, update: _updater.wrappedValue.objectWillChange.send)
}
確認用コード
@available(iOS 13.0, *)
final class Counter: ObservableObject {
@Published var number = 0
}
@available(iOS 13.0.0, *)
struct SwiftUIPlayGroundView: View {
@StateObservedObject var counter = Counter()
var body: some View {
VStack(spacing: 32) {
HStack {
Text("TopObject")
Spacer()
Text("\(counter.number)")
Button(action: {
counter.number += 1
}) {
ZStack {
Color.blue
.frame(width: 32, height: 32, alignment: .center)
Text("+")
.foregroundColor(.white)
.fontWeight(.bold)
}
}
}
ObservedCounterView()
StateCounterView()
BindingCounterView(counter: counter)
}
.padding(.all, 16)
}
}
@available(iOS 13.0.0, *)
struct ObservedCounterView: View {
@ObservedObject var var counter = Counter()
var body: some View {
HStack {
Text("ObservedObject")
Spacer()
Text("\(counter.number)")
Button(action: {
counter.number += 1
}) {
ZStack {
Color.blue
.frame(width: 32, height: 32, alignment: .center)
Text("+")
.foregroundColor(.white)
.fontWeight(.bold)
}
}
}
}
}
@available(iOS 13.0.0, *)
struct StateCounterView: View {
@StateObservedObject var counter = Counter()
var body: some View {
HStack {
Text("StateObject")
Spacer()
Text("\(counter.number)")
Button(action: {
counter.number += 1
}) {
ZStack {
Color.blue
.frame(width: 32, height: 32, alignment: .center)
Text("+")
.foregroundColor(.white)
.fontWeight(.bold)
}
}
}
}
}
@available(iOS 13.0.0, *)
struct BindingCounterView: View {
@ObservedObject var counter: Counter
var body: some View {
HStack {
Text("BindingObject")
Spacer()
Text("\(counter.number)")
Button(action: {
counter.number += 1
}) {
ZStack {
Color.blue
.frame(width: 32, height: 32, alignment: .center)
Text("+")
.foregroundColor(.white)
.fontWeight(.bold)
}
}
}
}
}
実行結果
おわりに
今回こちらの実装を作るにあたってこちらの記事を参考にさせていただきました。筆者のたなたつさん、ありがとうございます。一度この通りに書いてみたところ、値の更新がうまくいかなかったので、内部でobjectWillChangeを監視してupdate()するようにしました。(もしかしたら僕がミスしているだけかもしれないので、こうすればいいよ的な意見があればコメントしていただけると助かります。)
一番やっかいだったのは、Stateの内部で参照している値どのタイミングで変更されているのか、が全然掴めなかったことです。各場面でインスタンスIDを表示して確認することで時間はかかりましたがinit後のupdate直前で更新されていることを確認できました。
SwiftUIに関してはまだまだ手探りな感じですが誰かの役にたてれば幸いです。