Property Wrappers
SwiftUIのpropertyWrapperの前に、そもそもpropertyWrapperとは何かを説明させてください。
変数をラッピングして、アクセス(get/set)を制御するための仕組みです。
簡単な例を見てみましょう。
@propertyWrapper
struct HelloWorld {
private var text: String
init() {
text = ""
}
var wrappedValue: String {
get { return text }
set {
if newValue == "世界" {
text = "こんにちは, \(newValue)!"
return
}
text = "Hello, \(newValue)!"
}
}
}
クラスや構造体、enumに@peopertyWrapper
をつけることで定義できます。
定義したら、それを以下のように変数に適用することができます。
@HelloWorld var name: String
name
に何らかの値をセットすると、"Hello, (name)!"という値に変換してセットされるようになります。日本語で"世界"とセットしたときだけ、"こんにちは, 世界!"となるようにしてみました。
wrappedValue
propertyWrapperはラッピングした値を返す変数であるwrappedValue
を実装する必要があります。この値のget/setで、ラッピングした値を変更したりします。
projectedValue
追加でprojectedValueという変数を実装することもできます。ラッピングした値の状態を示したり、wrappedValue
に関するなんらかの情報を返すことができます。外からこの値にアクセスするには$
を頭につけなければいけません。例えば、
@propertyWrapper
struct HelloWorld {
var projectedValue: Bool
init() {
projectedValue = false
}
var wrappedValue {
// ... get ..
set {
if newValue == "世界" {
text = "こんにちは, \(newValue)!"
projectedValue = true
return
}
text = "Hello, \(newValue)!"
projectedValue = false
}
}
}
とすると、$name
で、Bool値が返ります。
class Hello {
@HelloWorld(text: "Hello, World!") var str: String
func sayHello() {
print(str) // Hello, World!
print("\($str)") // false
str = "世界"
print(str) // こんにちは, 世界!
print("\($str)") // true
}
}
「世界」という値をセットしたときだけ、projectedValue
がtrue
になるようにしています。
このようにprojectedValue
を使ってwrappedValue
の追加情報(投影値?)を返すことができます。
SwiftUI の propertyWrapper
以降はSwiftUIで定義されたpropertyWrapperについてみていこうと思います。
State
SwiftUIのviewは構造体(値)のため、変更不可です。ですが、変数に@State
をつけると、その変数に変更が入ったら自動でviewを再構築してくれます。なのでviewで変更する必要があるものは@State
をつけます。
struct GreetingView: View {
@State var message: String = ""
var body: some View {
VStack {
Text(message)
}
}
}
State
はprojecteValue
として、後述するBinding
を返します。上記の例だと、 $message
で、String
をBinding
でラップした値を返してくれます。
Binding
双方向データバインディングというやつです。Binding
の値を変更すると、その情報源(変数)に、値の変更を反映してくれます。逆に情報源を更新すれば、Binding
から取得できる値も更新されています。
State
のprojectedValue
($)は、自身の値と結びつくBinding
を返してくれるので、自身の値を更新してほしいときに渡します。
struct GreetingView: View {
@State var message: String = "ボタンは押さないでね★"
var body: some View {
VStack {
Text(message)
// Bindingする例
GreetingButton(message: $message)
// Bindingしない例
StateGreetingButton(message: message)
}
}
}
struct GreetingButton: View {
@Binding var message: String
var body: some View {
Button(action: {
self.message = "ボタン...押しましたね..."
}) {
Text("絶対に押すな!")
}
}
}
struct StateGreetingButton: View {
@State var messageState: String
var body: some View {
Button(action: {
self.messageState = "Bindingではないです"
}) {
Text("押してもいいですよ")
}
}
}
GreetingButton
で、Binding
であるmessage
を更新すると、情報源であるGreetingView
のmessage
に変更が反映されます。
しかし、StateGreetingButton
のボタンを押して、messageState
を変更しても、情報源であるGreetingView.message
には反映されません。
もう一つの例をあげると、Toggle
の引数isOn
にBinding
を渡しますよね。これは、Toggle
自身がisOn
に渡したBinding
の情報源を変更するためです。
@State var onOff: Bool = true
var body: some View {
Toggle(isOn: $onOff) {
Text("情報源(var onOff)の値を変えますよ: \(onOff.description)")
}
}
ObservedObject
ObservedObject
はObservableObject
を実装したクラスをwrappedValue
として持ちます。
struct PlayerViewView: View {
@ObservedObject var user: PlayerViewModel = PlayerViewModel()
var body: some View {
VStack {
Text("あなたは \(user.age) 歳です")
Button(action: {
self.user.haveBirthday()
}) {
Text("何歳ですか?")
}
}
}
}
final class PlayerViewModel: ObservableObject {
private(set) var age: Int = 0 {
willSet {
objectWillChange.send()
}
}
func haveBirthday() {
age += 1
}
}
上記の例だと、ボタンを押すたびに、PlayerView
へPlayerViewModel
の変更が通知され、viewが更新されます。
データフローは@State
と変わらないのですが、@ObservedObject
の場合は、wrappedValue
がObservableObject
のオブジェクトでないといけない、という点が異なります。
ObservableObject
ObservableObject
はCombine
フレームワークに含まれるプロトコルです。オブジェクトの変更を通知できることを表します。objectWillChange
というPublisher(変更イベントの発行元)を持っており、これを購読していれば、ObservableObject
の変更を検知できます。
ObservableObject
が自身に変更が入った、ということを通知するには、以下のようにobjectWillChange.send()
を実行します。
class SomeObservableObject: ObservableObject {
var hello: String {
willSet {
// ObservableObjectに適合するクラスは objectWillChangeを持っている
objectWillChange.send()
}
}
}
以下のように購読すれば、変更が入ったことを検知できます。
class Hoge {
var cancellable: AnyCancellable?
init() {
cancellable = someObservalbeObject.objectWillChange.sink { _ in
// objectWillChange.send() が呼ばれた!
}
}
}
@ObservedObject
のpropertyWrapperを使うと、SwiftUIがObservableObject
を購読し、変更が入るたびにviewを更新してくれます。
Published
SwiftUIではなく、Combineで定義されたpropertyWrapperですが、ObservableObject
でよく使われるので説明します。
変数に@Published
を付与すると、その変数のprojectedValue
は自身の変更を通知する Publisher
を返してくれます。
class Dog {
@Published var name: String = "Pero"
private var cancellable: AnyCancellable?
init() {
self.cancellable = $name.sink { newName in
print("あなたの名前: \(newName)")
}
}
}
let dog = Dog() // あなたの名前: Pero
dog.name = "John" // あなたの名前: John
dog.name = "Taro" // あなたの名前: Taro
sink
で購読し、値の変更を監視しています。変更が入るたびにログ出力しています。
ObservableObject
というプロトコルに適合したクラスで、@Published
の変数を持っている場合、それに対して変更すると、ObservableObject
の変更を自動で通知してくれます。
class Dog: ObservableObject {
@Published var name: String = "Pero"
private var cancellable: AnyCancellable?
init() {
self.cancellable = objectWillChange.sink {
print("名前、変わりましたね...")
}
}
}
let dog = Dog()
dog.name = "John" // 名前、変わりましたね...
なので、@Published
を使えば、自前でwillSet
にてobjectWillChange.send()
なんて書かずによいので楽です。
EnvironmentObject
ObservableObject
を子ビューでも使いまわしたい場合は、@ObservedObject
ではなく、@EnvironmentObject
を使用します。
struct ContentView: View {
@EnvironmentObject var setting: PlayerSettings
var body: some View {
VStack {
Text("あなたの通知設定: \(setting.isNotificationEnable.description)")
SubView()
}
}
}
struct SubView: View {
@EnvironmentObject var setting: PlayerSettings
var body: some View {
VStack {
Button(action: {
self.setting.isNotificationEnable.toggle()
}) {
Text("通知設定を変更")
}
}
}
}
/// View間で共有するオブジェクト
final class PlayerSettings: ObservableObject {
@Published var isNotificationEnable: Bool = false
}
上記の例だと、SubView
のsetting
は親View(ContentView
)のsetting
が自動でインジェクトされます。なので、子View側で、変更したら、親Viewにも反映されます。
ただし、最初に@EnvironmentObject
を使用しているViewの生成時に、オブジェクトを生成して渡す必要があります。渡さないとクラッシュします。
let contentView = ContentView().environmentObject(PlayerSettings())
もう一つ注意する点は、sheet
などで表示した親子関係でない別のViewには自動でインジェクトされないということです。共有したい場合は、明示的に渡す必要があります。
struct ContentView: View {
@EnvironmentObject var setting: PlayerSettings
@State var showModal: Bool = false
var body: some View {
VStack {
Text("あなたの通知設定: \(setting.isNotificationEnable.description)")
Button(action: {
self.showModal = true
}) {
Text("べつのがめん")
}.sheet(isPresented: $showModal) {
// 親子関係でないViewでも共有したい場合は、明示的に渡す必要がある
SubView().environmentObject(self.setting)
}
}
}
}
Environment
SwiftUIで定義されたViewの設定値を取得することができます。
struct SubView: View {
@Environment(\.isEnabled) var enable: Bool
var body: some View {
VStack {
Text("活性状態? \(enable.description)")
}
}
}
例えば上記画面の表示時に、以下のようにdisabled(true)
とすれば、@Environment(\.isEnabled)
はfalse
になります。
SubView().disabled(true)