It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

【SwiftUI】@Stateの基本的な使い方

概要

前回の記事では、propertyWrapperの基本について書きました。

inon29.hateblo.jp

SwiftUIではこのpropertyWrapperの機能を使って様々な機能が提供されています。
今回は、値の更新を検知してViewを再描画させるためのpropertyWrapper @State についてみていきます。

環境

@Stateとは

@State は Viewの更新を監視するための propertyWrapper で、SwiftUIを使って画面を実装するための基本的な機能の1つです。

@StateをつけたView内のプロパティが変更されるとViewの body プロパティが再評価されViewが更新されます。

次のコードは簡単なサンプルです。

struct ContentView: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button(action: {
                self.count += 1
            })
            {
                Text("count up")
            }
        }
    }
}

上記サンプルでは、count というViewプロパティが @Stateで宣言されています。
「count up」のラベルがつけられたボタンを押下すると count プロパティの値が1つずつ加算され、その変更を検知するたびにViewが再描画されます。

@Stateで宣言されたプロパティの特徴

@Stateで宣言されたプロパティの特徴で気になったものを記述します。

① mutating キーワードをつけた関数以外から更新できる

Viewは構造体のため、プロパティを構造体内部から更新するためには、mutating キーワードをつけたメソッドから更新する必要がありますが、@Stateをつけたプロパティは View内のどこからでも値の更新が可能 です。

② View内部と子View内からしか参照/変更できない

@Stateをつけたプロパティは、基本的にView内部(bodyのgetter内)とその子View内からのみ参照/更新されます。
そのため@Stateのプロパティは private として宣言するのが推奨のようです。

③ Viewが再描画(再初期化)されても@Stateの値を保持する

SwiftUIのView構造は、親View内から子Viewを宣言する階層構造になっており、基本的に親Viewが再描画されるタイミングでは子Viewも再描画されます。
(再描画時には、init() が呼ばれ構造体自体が再生成されます。)
その時、子Viewが @Stateのプロパティを持っていた場合、再描画されたタイミングで@Stateの値は初期化されません

次のサンプルをみてみます。

struct ParentView: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button(action: {
                self.count += 1
            })
            {
                Text("count up")
            }
            // NOTE: 子Viewの宣言
            ChildView()
        }
    }
}

struct ChildView: View {
    @State private var text = ""
    
    init() {
        // NOTE: 親ViewのStateが変わるたびにinitが呼び出される。
        print("init child view.")
    }
    
    var body: some View {
        VStack {
            // NOTE: 構造体が作り直されても @Stateで保持している値は初期化されない
            Text("Text: \(text)")
            Button(action: {
                self.text = "Hello"
            })
            {
                Text("set text")
            }
        }
    }
}

ParentViewは、count という @Stateのプロパティを持っています。 また、子Viewとして ChildViewをbody内で描画しています。
ChildViewは @Stateのプロパティとして text という文字列を持っています。

このコードを実行し、まずChildViewの「set text」ボタンを押下します。
このタイミングで、ChildViewの text プロパティに「”Hello”」という文字列がセットされます。

次に、ParentViewの「count up」ボタンを押下します。
このタイミングでcountプロパティが更新されParentViewは再描画されます。
同時にChildViewも作り直されます。

この時、先程設定した text プロパティの文字列「”Hello"」の値は構造体が変わっても初期化されずに残ります。
これは@StateのpropertyWrapperで管理する内部の値が独自のストレージ管理をされているからのようですが、詳細の仕組みまではよくわかっていません。

@Stateで宣言するプロパティのデータ型

@Stateで管理できるプロパティのデータ型について、基本的なパターンを見ていきます。

基本データ型

IntString など基本データ型を @Stateで管理する場合です。

struct ContentView: View {
    @State var name = ""

    var body: some View {
        return VStack {
            Text("name: \(name)")
            Button(action: {
                self.setName()
            }) {
                Text("set string name")
            }
        }
    }
    
    func setName() {
        name = "A"
    }
}

これは、特に意識せず値を再代入することでViewが再描画できます。

構造体

@Stateで管理するデータ型が構造体(struct)の場合です。

struct ContentView: View {
    struct User {
        var name = ""
    }
    @State var user: User = User()

    var body: some View {
        return VStack {
            Text("name: \(user.name)")
            Button(action: {
                self.setName()
            }) {
                Text("set struct name")
            }
        }
    }
    
    func setName() {
        user.name = "A"
    }
}

@Stateで管理するデータ型が構造体の場合、構造体のプロパティの値が変わってもViewは再描画されます。

func setName() {
    var u = User()
    u.name = "A"
    user = u
}

このように構造体自体を作り直す必要はありません。(上記でも再描画は発生します)

クラス

@Stateで管理するデータ型がクラス(class)の場合です。

struct ContentView: View {
    class User {
        var name = ""
    }
    @State var user: User = User()

    var body: some View {
        return VStack {
            Text("name: \(user.name)")
            Button(action: {
                self.setName()
            }) {
                Text("set class name")
            }
        }
    }
    
    func setName() {
        user.name = "A"
    }
}

クラスのプロパティを変更しても、Viewの変更は検知されずViewは再描画されません
ただし、次のようにインスタンス自体を更新した場合は、再描画されます。

func setName() {
    var u = User()
    u.name = "A"
    user = u
}

クラスを監視対象として使用したい場合は、別に @ObservedObject というpropertyWrapperが提供されているのでそちらを使うのが一般的です。