📝

SwiftUI のビューと変数の関係によるプロパティーラッパーの学習メモ

に公開

この記事について

SwiftUI を勉強していて、iOS 17 以降でサポートされる @Observable を前提に、SwiftUI で UI に利用する(UI の更新を発生させる)変数の管理方法を勉強したメモです。

含んでいないことなど

以前の ObservableObject やそれからの移行などについては調べていません。また、iOS 16以前のことも考慮していません。
あくまでも、現時点(2025年4月)で、これから新しく開発する場合に、iOS 17 以降を前提にして良い場合と割り切った内容です。

メモ

説明上、ビュー内の「変数」と言っていますが、厳密にはプロパティと書くべきなのかもしれません。とりあえずこの記事内では「変数」と「プロパティ」の区別はせず、すべて「変数」としています。

環境など

作成時期: 2025年4月5日
各種バージョン:

% sw_vers             
ProductName:		macOS
ProductVersion:		15.3.2
BuildVersion:		24D81
% xcodebuild -version
Xcode 16.3
Build version 16E140
% swift --version    
swift-driver version: 1.120.5 Apple Swift version 6.1 (swiftlang-6.1.0.110.21 clang-1700.0.13.3)
Target: arm64-apple-macosx15.0

アプリの画面イメージ

同じ画面のアプリを、ビューとデータの関係のパターン別に作成してみた。

  • 複数の変数がある
  • 単一のビューでも、階層化したビューでも表現できる

ことを意識して作ってみたサンプルのアプリ。
ボタンをクリックすると、その時点の「日付」と「時刻」をそれぞれの文字列変数に格納して、それを別の Text ウィジェットで更新して表示するだけ。
padding などの設定で、細かなUIは若干違っている可能性がありますが、すべてのパターンで同じ動作をしているはず。

ビューと変数の関係別のプロパティーラッパーの使い方

1. 単一ビューで完結している場合: @State

結論: 単一のビューの中で変数定義、操作、表示が揃っている場合は、変数に @State プロパティーラッパーを使用する。

単一のビュー(以下の例の場合 ContentView) の中に

  • 変数 var date, var time の定義
  • 変数を表示する2つの Text Text("\(date)"), Text("\(time)")
  • 変数の値を更新する関数(を持つボタン) Button("現在時刻の取得")

がすべて存在している。
この場合は、変数の前に @State を付加する。

import SwiftUI

 struct ContentView: View {
    @State var date = "(未取得)"
    @State var time = "(未取得)"

    var body: some View {
        VStack (spacing: 10) {
            Text("クリックした日時は")
                .font(.headline)
            Text("\(date)")
                .font(.largeTitle)
            Text("\(time)")
                .font(.largeTitle)
            Button("現在時刻の取得") {
                let dateFormatter = DateFormatter()
                dateFormatter.dateFormat = "yyyy/MM/dd"
                let timeFormatter = DateFormatter()
                timeFormatter.dateFormat = "HH:mm:ss"

                let now = Date()
                date = dateFormatter.string(from: now)
                time = timeFormatter.string(from: now)

            }
            .padding()
            .accentColor(.white)
            .background(.blue)
            .cornerRadius(20)
        }
        .padding()
    }
 }

#Preview {
    ContentView()
}

2. 親子ビュー間で変数を持ち合う : @State -> @Binding

結論: 親ビューの中で @State をつけた変数を、子ビューで @Binding で受け取って使う

以下のコード例では、簡単にするために、ひとつにまとめていますが、

  • 変数定義を含む ContentView

と、その中に配置される

  • Text をもつ構造体 LeatTextLabel, DateLabel, TimeLabel
  • Button を持つ構造体 UpdateButton

が分離しています。
つまり、親ビュー(ContentView) の中に

  • 変数 var date, var time の定義

があり、

  • 変数を表示する2つの Text
  • 変数の値を更新する関数(を持つボタン)

は別のビューに(子ビュー)である状態です。
この場合は、(1)親ビューで @State をつけて定義された変数を、(2)子ビューのインスタンスに "$"変数名 で(参照を)渡して、(3)子ビューでは @Binding をつけた変数で使います。
以下、コード内ではシンプルにするために date 変数についてのみ、上記の内容をコメントしています。

import SwiftUI

struct ContentView: View {
    @State var date = "(未取得)" // (1)親ビューで @State をつけて定義された変数を
    @State var time = "(未取得)"

    var body: some View {
        VStack(spacing: 10) {
            LeadTextLabel()
            DateLabel(date: $date) // (2)子ビューのインスタンスに "$"変数名 で(参照を)渡して
            TimeLabel(time: $time)
            UpdateButton(date: $date, time: $time)
        }
    }
}

struct LeadTextLabel: View {
    var body: some View {
        Text("クリックした日時は")
            .font(.headline)
    }
}

struct DateLabel: View {
    @Binding var date: String // (3)子ビューでは @Binding をつけた変数で使う

    var body: some View {
        Text("\(date)")
            .font(.largeTitle)
    }
}

struct TimeLabel: View {
    @Binding var time: String

    var body: some View {
        Text("\(time)")
            .font(.largeTitle)
    }
}

struct UpdateButton: View {
    @Binding var date: String
    @Binding var time: String

    var body: some View {
        Button("現在時刻の取得") {
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = "yyyy/MM/dd"
            let timeFormatter = DateFormatter()
            timeFormatter.dateFormat = "HH:mm:ss"

            let now = Date()
            date = dateFormatter.string(from: now)
            time = timeFormatter.string(from: now)
        }
        .padding()
        .accentColor(.white)
        .background(.blue)
        .cornerRadius(20)
    }
}

#Preview {
    ContentView()
}

3. 変数にカスタムクラス利用する場合 : @Observable -> @State

結論: 変数にカスタムクラスを利用する場合、クラスに @Observable をつけて、そのインスタンスを @State をつけた変数にして使う
ここまでの例では変数は文字列などの単純な型だったが、変数にカスタムクラスを使用したい例として、datetime をプロパティとして持つ DateAndTime クラスを考えてみる。
この場合、まずカスタムクラス DateAndTime クラスに @Observable をつける。

import Observation // Observation をインポートして

@Observable // @Observable をつける
class DateAndTime {
    var date = "(未取得)"
    var time = "(未取得)"
}

そして、ビューの中で @State をつけてカスタムクラスのインスタンスを変数として定義する。
以下は、変数がカスタムクラスである以外は、単一ビューで完結している(他のビューに受け渡しが発生しない) 1. と同じ状態にした例。

import SwiftUI

struct ContentView: View {
    @State var dateAndTime = DateAndTime() // カスタムクラスを変数に定義

        var body: some View {
            VStack (spacing: 10) {
                Text("クリックした日時は")
                    .font(.headline)
                Text("\(dateAndTime.date)") // カスタムクラス内のプロパティを表示
                    .font(.largeTitle)
                Text("\(dateAndTime.time)")
                    .font(.largeTitle)
                Button("現在時刻の取得") {
                    let dateFormatter = DateFormatter()
                    dateFormatter.dateFormat = "yyyy/MM/dd"
                    let timeFormatter = DateFormatter()
                    timeFormatter.dateFormat = "HH:mm:ss"
    
                    let now = Date()
                    dateAndTime.date = dateFormatter.string(from: now)
                    dateAndTime.time = timeFormatter.string(from: now)

                }
                .padding()
                .accentColor(.white)
                .background(.blue)
                .cornerRadius(20)
            }
            .padding()
        }
     }

#Preview {
    ContentView()
}

4. 任意のビューでカスタムクラス内の変数を利用する @State + .environment -> @Environment

結論: 親子ビューで変数を受け渡すのではなく、任意のビューで変数を使用する場合は、ルートとなるビューで @State で定義したビューを .environment に設定しておき、使用したいビューで @Environment で受け取って使う

例として、3. と同じカスタムクラスを利用した変数を、2. と同じように分離した下位のビューで使用する。
(1)ルートとなるビューで、@State で変数を定義し、(2)ContentView に .environment で設定する。
そうしておくと、(ContentView よりもさらに下の階層にある) Text や Button など、(3)変数を使用したいビューで、@Environment で受け取って利用することができる。

// 3. と同じ
import Observation

@Observable
class DateAndTime {
    var date = "(未取得)"
    var time = "(未取得)"
}

import SwiftUI

@main
struct SwiftUI_state_selfstudyApp: App {
    @State var dateAndTime = DateAndTime() // (1)ルートとなるビューで、@State で変数を定義し

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(dateAndTime) // (2)ContentView に .environment で設定する
        }
    }
}

// このビューでは変数を利用していない
import SwiftUI

struct LeadTextLabel: View {
    var body: some View {
        Text("クリックした日時は")
            .font(.headline)
    }
}

struct DateLabel: View {
    @Environment(DateAndTime.self) var dateAndTime // (3)変数を使用したいビューで、@Environment で受け取って利用する

    var body: some View {
        Text("\(dateAndTime.date)")
            .font(.largeTitle)
    }
}

struct TimeLabel: View {
    @Environment(DateAndTime.self) var dateAndTime

    var body: some View {
        Text("\(dateAndTime.time)")
            .font(.largeTitle)
    }
}

struct UpdateButton: View {
    @Environment(DateAndTime.self) var dateAndTime

    var body: some View {
        Button("現在時刻の取得") {
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = "yyyy/MM/dd"
            let timeFormatter = DateFormatter()
            timeFormatter.dateFormat = "HH:mm:ss"

            let now = Date()
            dateAndTime.date = dateFormatter.string(from: now)
            dateAndTime.time = timeFormatter.string(from: now)
        }
        .padding()
        .accentColor(.white)
        .background(.blue)
        .cornerRadius(20)
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: 10) {
            LeadTextLabel()
            DateLabel()
            TimeLabel()
            UpdateButton()
        }.padding()
    }
}

#Preview {
    ContentView()
        .environment(DateAndTime()) // Preview に .environment で変数を与える必要がある
}

まとめ

実際に作って記事にまとめてみるまでは、4つのプロパティーラッパーの違いがよくわからず、変数の定義方法の違いのような勘違い(イメージ)をしていた。
現時点での理解は、変数の定義(実際に値を格納するメモリ)は @State のみで、@Binding と @Environment はその場所(参照)をどう渡すか、@Observable はカスタムクラスを @State に「入れられるようにする」か、という役割だというイメージ。
このイメージが合っているか怪しいが、色々な説明で、各プロパティラッパーが、用途別に並列で書かれていると思ってしまっていたことが原因のような気がするので、この記事では @State -> @Binding といった表現をしてみました。
SwiftUI に限らず、プログラミング自体勉強中なので、コメントでアドバイスをいただけると助かります。

Discussion