- 2019/7/17
@GestureState
について追記しました。 - 2019/7/19 BindableObjectがdidChange->willChangeなど変更されていたので修正しました。
https://developer.apple.com/documentation/swiftui/bindableobject - 2019/7/30 Xcode11 Beta5でBindableObjectはdeprecatedになり
ObservableObject
を使うようになったので追記しました。@ObjectBinding
も@ObservedObject
に名前が変わりました。 - 2019/7/31
ObservableObject
がCombineに含まれること、Identifiable
はStandard Libraryに移動したことについて記載しました。また@Published
をつけるとwillChangeなどの記載が不要になりました。 - 2019/9/18 Property WrappersのwrappedValueとprojectedValueについて追記しました。
- 2020/6/28
@AppStorage
@SceneStorage
@StateObject
について追記しました。 - 2020/8/8
@State
、@ObservedObject
の使用方法について一部修正と追記をしました。
SwiftUIの実装をしている中で
@State
などのProperty Wrappersの違いを理解するために
セッションの動画なども含めて調べてみました。
※ Property Wrappersについては最後の方で紹介しています。
Data Flow Through SwiftUI
https://developer.apple.com/videos/play/wwdc2019/226/
まだ学び始めたばかりの認識ですので
もし勘違いしている点などありましたら
教えていただけますと嬉しいです
簡単にまとめると下記のような感じです。
Property Wrapper | 使用方法 |
---|---|
@State |
特定のView内でStringやIntなどのシンプルなデータに使用する |
@StateObject |
ObservableObjectをインスタンス化する唯一の場所として使用する |
@ObservedObject |
親Viewで所有しているObservableObjectを直系の子Viewでも読み書きできるようにしたい場合に使用する |
@EnvironmentObject |
Viewの階層構造でObservableObjectを広く使用したい場合に利用する(親ViewのObservableObjectを孫Viewでも使用したいなど) |
@State
Viewはstructのため
通常の値を更新することができませんが
@State
を宣言することで
メモリの管理がSwiftUIフレームワークに委譲され
値を更新することができるようになります。
その際にはViewの再構築を自動でしてくれるようになります。
内部的には差分アルゴリズムを使って
関連のあるViewのみ再構築をしてくれるようです。
State
https://developer.apple.com/documentation/swiftui/state
struct ContentView : View {
@State var value: Bool = false
var body: some View {
Toggle(isOn: $value) {
Text("The value is " + "\($value)")
}
}
}
@State
は宣言したプロパティを保持するViewと
その子Viewからしかアクセスができません。
使いどころ
@State
は宣言するたびに
新しい状態を生み出し(SwiftUIのメモリ上に新しくアロケートされ)ます。
そのため初期値の設定が必要になります。
そのため
外部からのデータを参照するなどには適さず
ローカルでしか使用しないデータのアクセスに適しているようです。
※ ローカルでしか使用しないとは
Button
をクリックしたときのHighlight
など
そのView
内のみで使用されるようなものを想定しています。
また、classなどで利用すると
インスタンス自体は変更がないため
内部のプロパティが変化しても
Viewの再描画は発生しません。
そのためIntやStringなどのシンプルなデータを
扱う場合などに適しています。
structなどのValue Typeも
値をコピーする性質から状態の共有をすることができないため
Value Typeにも@State
を活用することはできます。
@State
のプロパティはprivate
修飾子が付けられるような形で
使用することが好ましいようです。
BindableObjectと@ObjectBinding
(Xcode11 Beta5よりdeprecated)
値の更新を通知するクラスがBindableObject
プロトコルに適合することで
@ObjectBinding
を宣言したプロパティへ値の更新通知を行うようになります。
BindableObject
はAnyObject
に適合しているため
参照型にしか使用できません。
更新の通知方法などは開発者側で実装する必要があります。
@ObjectBinding
を宣言したプロパティは
SwiftUIに依存関係があることを明示的に伝え
値の更新通知を自動で受けとることができるようになります。
動き自体は@State
に似ていますが、
Viewの階層をまたがって
値の更新通知を受け取ることができます。
@ObjectBinding
https://developer.apple.com/documentation/swiftui/objectbinding
BindableObject
https://developer.apple.com/documentation/swiftui/bindableobject
class ViewModel: BindableObject {
let willChange = PassthroughSubject<ViewModel, Never>()
var isEnabled = false {
willSet {
willChange.send(self)
}
}
}
struct ContentView : View {
@ObjectBinding(initialValue: ViewModel()) var viewModel: ViewModel
var body: some View {
Toggle(isOn: $viewModel.isEnabled) {
Text("The value is " + "\(viewModel.isEnabled)")
}
}
}
子Viewにデータを渡す際はinit
を使って
Viewの階層を辿って依存関係を宣言していく必要があります。
使いどころ
複数のViewにまたがって参照されるクラスなどに使用するのに適しています。
すでに存在しているViewを表示するためのモデルや
データベースへの保存に必要なモデルなど
SwiftUIの外部のデータを参照する場合に適しているようです。
ObservableObjectと@ObservedObject
(2019/7/30 追記)
Xcode11 Beta5でBindableObject
は非推奨になり
ObservableObject
を使うようになったようです。
BindableObject
はObservableObject
とIdentifiable
に適合するようになっています。
これはSwiftUIではなくCombineフレームワークに含まれます。
ちなみにIdentifiable
はStandard Libraryに移動しました。
また、@ObjectBinding
も@ObservedObject
に名前が変わりました。
@ObjectBinding
は@ObservedObject
のtypealias
になっています。
public typealias ObjectBinding = ObservedObject
@ObservedObject
https://developer.apple.com/documentation/swiftui/observedobject
ObservableObject
https://developer.apple.com/documentation/combine/observableobject
Identifiable
https://developer.apple.com/documentation/swift/identifiable
class ViewModel: ObservableObject {
let willChange = PassthroughSubject<ViewModel, Never>()
var isEnabled = false {
willSet {
willChange.send(self)
}
}
}
struct ContentView : View {
@ObservedObject var viewModel: ViewModel
var body: some View {
Toggle(isOn: $viewModel.isEnabled) {
Text("The value is " + "\(viewModel.isEnabled)")
}
}
}
(2019/7/31 追記) @Published
で自動通知をするようになりました。
Xcode11 Beta5より
@Published
を使用すると上記のようなwillChange
の記載する必要がなくなりました。
import Combine
import SwiftUI
class ViewModel: ObservableObject {
@Published var isEnabled: Bool = false
}
struct ContentView : View {
@ObservedObject var viewModel: ViewModel
var body: some View {
Toggle(isOn: $viewModel.isEnabled) {
Text("The value is " + "\(viewModel.isEnabled)")
}
}
}
※(2020/8/8 追記)
ObservedObject
で自らインスタンスを生成すると
いくつかの問題が起きる可能性があります。(下記StateObject
参照)
また@StateObject
の登場により
少し役割が変わってきた印象があり
@ObservedObject
は
親Viewで生成された
@StateObject
が宣言されたObservableObject
を子Viewに渡すことで
子Viewでもその値を読み書きできるようにしたい場合などに
使用されます。
こうすることで
親Viewが保持しているものが
唯一の変更点とすることができ
いわゆるSingle Source of Truthが保証できます。
さらに
使用する場合は
親Viewとその直系の子Viewとの間で使用する
のが良いようです。
それ以上に深い階層に渡す必要がある場合は
下記の@EnvironmentObject
を使用します。
environmentObjectと@EnvironmentObject
Environmentとは
「SwiftUI Essentials」のセッションで出てきましたが
SwiftUIの特徴の1つで
Viewの階層の中でコンテキストを共有するコンテナのような存在です。
SwiftUI Essentials
https://developer.apple.com/videos/play/wwdc2019/216/
environmentObjectと@EnvironmentObject
を使うと
上記のEnvironmentの中にカスタムな値を設定できます。
environmentObject
を使用したViewのEnvironment内で
引数に渡したObservableObject
の値を
@EnvironmentObject
から参照できます。
@EnvironmentObject
https://developer.apple.com/documentation/swiftui/environmentobject
environmentObject
https://developer.apple.com/documentation/swiftui/view/3308681-environmentobject
class ViewModel: ObservableObject {
let willChange = PassthroughSubject<ViewModel, Never>()
var isEnabled = false {
willSet {
willChange.send(self)
}
}
}
struct ContentView : View {
@EnvironmentObject var viewModel: ViewModel
var body: some View {
Toggle(isOn: $viewModel.isEnabled) {
Text("The value is " + "\(viewModel.isEnabled)")
}
}
}
let view = ContentView().environmentObject(ViewModel())
@ObservedObject
は子Viewへデータを渡すのに
init
を使用してViewの階層を辿っていく必要がありましたが
@EnvironmentObject
を使うとその必要なしに値を参照することができます。
内部的に@EnvironmentObject
はdynamic member lookupを使用しており
そのEnvironmentのObservableObject
を探してくれます。
使いどころ
Environment全体の設定に関わる値など
@ObservedObject
よりも広く
アプリ全体で使用する必要があるようなデータに適しているようです。
@Environment
EnvironmentValues
という事前に定義されているプロパティの値を使って
Environment全体にまたがるような設定の取得、更新ができます。
Environment
https://developer.apple.com/documentation/swiftui/environment
EnvironmentValues
https://developer.apple.com/documentation/swiftui/environmentvalues
struct ContentView : View {
@Environment(\.isEnabled) var enabled: Bool
var body: some View {
Text("The value is " + "\(enabled)")
}
}
値の変更も可能です。
使いどころ
上記でも記載した通り
EnvironmentValues
で定義されている値を参照するために使用します。
@Binding
値を参照する側のプロパティに宣言をすることで
親Viewの@State
や
下記で紹介する@ObservedObject
@Binding
の
値の更新通知を受け取ることができます。
@Binding
https://developer.apple.com/documentation/swiftui/binding
@Binding
では値を参照します。
ViewはValue Typeで
親Viewから子Viewへ値を直接渡してもコピーを渡してしまいますが、
@Binding
を使用することで
Value Typeへの参照を渡して親子間でデータの同期を保つことができます。
使いどころ
ViewはImmutableであるのが好ましいものの
値の更新をする必要がある場合などは
基本的には@Binding
を使うのが良いとセッションでは言っています。
@GestureState
2019/7/17追記
特定のViewへのジェスチャーイベントを受け取ることができます。
注意点としては
イベント終了後に変化した値は保持されないという点です。
使い方は受け取りたいイベントの変数を作成し
gesture
を使用します。
struct ShapeTapView: View {
var body: some View {
let tap = TapGesture()
.onEnded { _ in
print("View tapped!")
}
return Circle()
.fill(Color.blue)
.frame(width: 100, height: 100, alignment: .center)
.gesture(tap)
}
}
コールバックを受け取るには3つの方法があります。
updating(_:body:)
ジェスチャーの変化ごとにViewを更新していく場合などに使用します。(例えばズームやドラッグなど)
ジェスチャーが完了したりキャンセルされるとコールバックを受け取られなくなります。
この値はジェスチャー中だけ保持され
ジャスチャーが止まると値はリセットされます。
下記の例はロングプレス中は円の色が変わりますが
指を離すと色は元の色に戻ります。
struct CounterView: View {
@GestureState var isDetectingLongPress = false
var body: some View {
let press = LongPressGesture(minimumDuration: 1)
.updating($isDetectingLongPress) { currentState, gestureState, transaction in
gestureState = currentState
}
return Circle()
.fill(isDetectingLongPress ? Color.yellow : Color.green)
.frame(width: 100, height: 100, alignment: .center)
.gesture(press)
}
}
onChanged(_:)
ジャスチャーの値が変化した時にコールバックを受け取ることができます。
ジェスチャーが完了した後も変化した値を保持したい場合に@State
と組み合わせて使用します。
下記の例はロングプレスをしている間はカウントが上がっていきます。
@State
のtotalNumberOfTaps
で
ロングプレスが終わった後も値を保持するようにしています。
struct CounterView: View {
@GestureState var isDetectingLongPress = false
@State var totalNumberOfTaps = 0
var body: some View {
let press = LongPressGesture(minimumDuration: 1)
.updating($isDetectingLongPress) { currentState, gestureState, transaction in
gestureState = currentState
}.onChanged { _ in
self.totalNumberOfTaps += 1
}
return VStack {
Text("\(totalNumberOfTaps)")
.font(.largeTitle)
Circle()
.fill(isDetectingLongPress ? Color.yellow : Color.green)
.frame(width: 100, height: 100, alignment: .center)
.gesture(press)
}
}
}
onEnded(_:)
ジェスチャーが完了後に最終的な値を受け取ることができます。
成功した場合のみコールバックを受け取ることができます。
例えばロングプレスのようなジェスチャーをしたものの
設定しているminimumDuration
に到達していない場合や
maximumDistance
を超えて動かした場合は
コールバックを受け取ることはできません。
minimumDuration
https://developer.apple.com/documentation/swiftui/longpressgesture/3270364-minimumduration
maximumDistance
https://developer.apple.com/documentation/swiftui/longpressgesture/3077970-maximumdistance
下記の例は
ロングプレスをしている間はカウントを上げていきますが
指を離すとカウントは上がらなくなります。
struct CounterView: View {
@GestureState var isDetectingLongPress = false
@State var totalNumberOfTaps = 0
@State var doneCounting = false
var body: some View {
let press = LongPressGesture(minimumDuration: 1)
.updating($isDetectingLongPress) { currentState, gestureState, transaction in
gestureState = currentState
}.onChanged { _ in
self.totalNumberOfTaps += 1
}
.onEnded { _ in
self.doneCounting = true
}
return VStack {
Text("\(totalNumberOfTaps)")
.font(.largeTitle)
Circle()
.fill(doneCounting ? Color.red : isDetectingLongPress ? Color.yellow : Color.green)
.frame(width: 100, height: 100, alignment: .center)
.gesture(doneCounting ? nil : press)
}
}
}
参考ドキュメント
https://developer.apple.com/documentation/swiftui/gestures/adding_interactivity_with_gestures
(2020/6/28追記)@AppStorage
UserDefaults
の値の読み書きができます。
この値が変更されるたびにSwiftUIのViewの再描画が発生します。
struct ContentView: View {
@AppStorage("name") var name: String = "Someone"
var body: some View {
VStack {
Text("Hello, \(name)!")
Button("say hello") {
self.name = "Tom"
}
}
}
}
これは以前と同じ方法でもViewの更新は発生するようです。
UserDefaults.standard.set("Tom", forKey: "name")
デフォルト値やstandard以外のUserDefaults
も指定できます。
@AppStorage("name", store: UserDefaults(suiteName: "shiz.otherSuite"))
var name: String = "Someone"
@AppStorage(wrappedValue: "Bob", "name")
var name: String = "Someone"
使いどころ
変更のたびにViewの更新が発生してしまうため
変更がアプリ全体に影響するもので
頻繁に更新が発生しないものなどに良いのではないかと言われています。
参考ドキュメント
https://developer.apple.com/documentation/swiftui/appstorage
(2020/6/28追記)@SceneStorage
Scene
単位で値が共有できる仕組みで
フレームワーク側で
SceneStorage
の値を管理し
初期化時に前回保持していた値があった場合は
自動で設定をしてくれるようです。
@AppStorage
とは異なり
UserDefaults
を利用はせず
内部に値を保持しているようです。
これまでにもあった
state restoration
と
同じような仕組みであると思っています。
例えば
@SceneStorage("text") var text = ""
と宣言すると
利用している全てのScene
で
それぞれtext
のコピーを保持するようになります。
ただし
注意点もあるようです。
必ず保存されるという保証はない
The system makes no guarantees
as to when and how often the data will be persisted.
とあり
データが必ず保存されることは保証されていないようです。
大きいデータは保存しない
Ensure that the data you use with SceneStorage is lightweight.
とあり
大きなデータを保存すると
パフォーマンスに影響があるので避けるように
と書かれています。
Sceneがなくなるとデータも消える
If the Scene is explictly destroyed,
the data is also destroyed.
とあり
Sceneが解放される(Switcherからプロセスをキルするなど)と
中身データも削除されるようです。
センシティブなデータは保存しない
Do not use SceneStorage with sensitive data.
とあります。
これは外部から見られる可能性があるからだと思います。
使いどころ
マルチウィンドウのアプリの場合
複数のScene
を利用することがありますが
その際に各Scene
の状態を保持することなどに利用します。
参考ドキュメント
https://developer.apple.com/documentation/swiftui/scenestorage
(2020/6/28追記)@StateObject
Viewはstruct
であるために
参照を保持するために @ObservableObject
を使用していました。
しかし
下記のような場合に問題が発生します。
import SwiftUI
final class Counter: ObservableObject {
@Published var count: Int = 0
}
struct CounterView: View {
@ObservedObject var counter = Counter()
var body: some View {
VStack {
Text("CounterView")
Text("\(counter.count)")
Button("increment") {
counter.count += 1
}
}
.padding()
.background(Color.yellow)
}
}
struct ContentView: View {
@State var count: Int = 0
var body: some View {
VStack {
Text("ContentView")
Text("\(count)")
CounterView()
Button("increment") {
count += 1
}
}
.padding()
.background(Color.red)
}
}
CounterView
のボタンを押すと
CounterView
のcount
は増加しますが
その後
ContentView
のボタンを押すと
CounterView
のcount
はクリアされてしまいます。
これは
ContentView
のText
が
@State
の値を利用しているため
@State
の変化に応じて
ContentView
は再描画が発生します。
その際に
@ObservedObject var counter = Counter()
の部分でCounter
が初期化されてしまうからです。
この問題を解消するためには
@ObservedObject
を@StateObject
に置き換えます。
その際に
@StateObject var counter = Counter()
@StateObject
は
一度初期化されると
フレームワーク側で参照を保持し続けてくれるようになり
再描画ごとに初期化が発生することがなくなります。
そして
内部に@ObservedObject
を保持しており
利用される際には@ObservedObject
の値を利用します。
使いどころ
ローカルの状態を保持したい時に
これまで@ObservedObject
を利用しようとしていたところで
@StateObject
を利用するのが良いのではないかと思います。
一方で
他からイニシャライザを経由して利用するような場合は
これまで通り@ObservedObject
を利用することで
よりコードを読んでいる人へ意図を伝えやすくなるのではないかと思います。
参考ドキュメント
https://developer.apple.com/documentation/swiftui/stateobject
下記のセッションで詳しく紹介されています。
https://developer.apple.com/videos/play/wwdc2020/10040/
セッションの内容を見ていく
ここからはセッションの内容についてもう少し見ていきたいと思います。
データフローの原理
データを読み込む全てのViewは依存関係を構築する
データの更新が発生するとViewが更新され
データとViewの間には依存関係が生まれます。
データを読み込むViewは情報源を持っている
データには発生元が必ず存在し
Viewはそれに対しての参照を持っています。
Single Source Of Truthを目指す
情報源はいくつも生成することができますが
特定の情報に対する情報源は1つであるべきです。
そうしないとデータの不整合などのバグを生み出す要因になります。
変更が加えられたときはその唯一の情報源から変化を伝達させるようにします。
SwiftUIの更新の仕組み
SwiftUIは上記のデータフローの原理を簡単に実現できるようになっています。
View
はstruct
なので自身の値を更新できません。
その代わりに
Combine
フレームワークのPublisher
や
Property Wrapper
を使ってデータの更新をView
に伝達します。
これらを利用することで
データの流れが一方向になり予測可能で理解しやすくします。
これまではデータの同期を取るために
UIViewController
が必要になりましたが
SwiftUIではその必要もなくなりよりシンプルな仕組みになっています。
それではそれぞれの伝達方法について見ていきたいと思います。
CombineフレームワークのPublisher
イベント駆動型の非同期処理を簡単に扱えるようにしたフレームワーク
https://developer.apple.com/documentation/combine
Publisher
がEmitしたイベントをonReceive
で受け取ることができます。
https://developer.apple.com/search/?q=onReceive
Combineフレームワークについてはこちらにも書かせていただきました。
https://qiita.com/shiz/items/5efac86479db77a52ccc
Property Wrappers
Swift5.1で導入された新しい機能で
冒頭で紹介している@State
@ObservedObject
@Binding
などもProperty Wrappersです。
アノテーション(@)を使って宣言したプロパティへの独自のアクセス方法を定義することができます。
そしてまだ今後に向けて議論は続いているようです。
https://forums.swift.org/t/pitch-3-property-wrappers-formerly-known-as-property-delegates/24961/201
※以前は Property Delegates
と呼ばれていました。
これを使用することで
共通のデータアクセス方法を一箇所で定義でき
重複コードの削減をすることができます。
「Modern Swift API Design」セッションで
仕組みが詳しく解説されています。
内部でStored Propertyを持ち
それに対応したComputed Propertyで
Stored Propertyにどうアクセスするのかを定義します。
@propertyWrapper
public struct LateInitialized<Value> {
private var storage: Value?
public init() {
storage = nil
}
public var value: Value {
get {
guard let value = storage else {
fatalError("value has not yet been set!")
}
return value
}
set {
storage = newValue
}
}
}
public struct MyType {
@LateInitialized public var text: String
// Compiler-synthesized code...
var $text: LateInitialized<String> = LateInitialized<String>()
public var text: String {
get { $text.value }
set { $text.value = newValue }
}
}
初期値を渡したり
extension
で新しくinit
を定義することなども可能です。
@propertyWrapper
public struct DefensiveCopying<Value: NSCopying> {
private var storage: Value
public init(initialValue value: Value) {
storage = value.copy() as! Value
}
public var value: Value {
get { storage }
set {
storage = newValue.copy() as! Value
}
}
}
@DefensiveCopying public var path: UIBezierPath = UIBezierPath()
extension DefensiveCopying {
public init(withoutCopying value: Value) {
storage = value
}
}
public struct MyType {
@DefensiveCopying(withoutCopying: UIBezierPath())
public var path: UIBezierPath
}
Modern Swift API Design
https://developer.apple.com/videos/play/wwdc2019/415
「What's New in Swift」セッションの中でも
UserDefaults
へのアクセスパターンが紹介されていました。
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
init(_ key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var value: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
@UserDefault("USES_TOUCH_ID", defaultValue: false)
static var usesTouchID: Bool
@UserDefault("LOGGED_IN", defaultValue: false)
static var isLoggedIn: Bool
if !isLoggedIn && usesTouchID {
!authenticateWithTouchID()
}
What's New in Swift
https://developer.apple.com/videos/play/wwdc2019/402/
追記
この後にvalueはwrappedValueに変更されています。
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
init(_ key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
さらにprojectedValue
を定義することで
$
を通して変数へアクセスをすることができるようになりました。
例えば@State
の場合
Bindingでラップされた値へアクセスすることができるようになります。
@propertyWrapper public struct State<Value> : DynamicProperty {
...
/// Produces the binding referencing this state value
public var projectedValue: Binding<Value> { get }
}
まとめ
SwiftUIのProperty Wrappersと
データへのアクセス方法について見てきました。
まずはセッションの動画から
どういう使われ方が期待されているのか
注意するべきところはどういうところなのか
などを少し理解することはできたのかなと思っています。
まだまだチュートリアル程度しか触れていないので
これから実践していく中で色々な発見をするのが楽しみです!