🐱

SwiftUIでレイアウト、データ、ロジックを分けて管理

に公開

概要

SwiftUIではレイアウト、データ、ロジックを統合的に記載・管理することができ直感的であるものの、大規模開発だったりチーム開発だったりそれなりの開発規模になるとデータを独自に管理したくなります。
SwiftUIにレイアウトは任せつつ、いわゆるデータとロジックを切り出してみます。

環境

  • macOS: Sequoia 15.5
  • Xcode: 16.4

SwiftUIのレイアウト

Xcodeで新規プロジェクトを作成した際に生成されたソースコードを眺めてみます。

ContentView.swift
struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

ContentViewに画面のレイアウトが記述されています。
VStackはコンテナで中身を縦方向に配置します。
Imageは画像の表示します。
Textは文字の表示します。
VStack内にImageTextの順に記載しているので、縦方向に上側が地球マーク、下側にHello, world!が表示されます。
SwiftUI layout

SwiftUIのビジネスロジック

例えば数値を表示するテキストと数値がインクリメントするボタンが表示されるアプリケーションの場合、SwiftUIでは画面にボタンを表示するところと押した時の処理を以下のように一度に記載できます。

ContentView.swift
struct ContentView: View {
    @State var count: Int = 0
    
    var body: some View {
        VStack {
            Text("\(count)")
            Button("カウント") {
                count += 1
            }
        }
        .padding()
    }
}

数値を保持する部分もContentView内に記載しています。
変数に@StateというProperty Wrapperを付加しています。
@Stateを付けると値の変更が可能になり値が監視されます。値の変更時にViewが再描画されます。
SwiftUI logic

レイアウトとビジネスロジックの分離

Viewにレイアウトとビジネスロジックが混在してると分かりにくいので分離したいという場合はいくつか方法があります。

関数を作成して呼び出す

関数を作成して呼び出すようにすればロジックをbodyの外へ逃すことができます。

ContentView.swift
struct ContentView: View {
    @State var count: Int = 0
    
    var body: some View {
        VStack {
            Text("\(count)")
            Button("カウント") {
                countUp()
            }
        }
        .padding()
    }

    func countUp() {
        count += 1
    }
}

クラスを作成して呼び出す

関数を作成する方法だとContentViewが肥大化してしまうので、ContentViewの外へ逃すためにクラスを作成します。

ContentView.swift
@Observable class Utility {
    var count: Int = 0
    
    func countUp() {
        count += 1
    }
}

struct ContentView: View {
    @State var utility: Utility = Utility()
    
    var body: some View {
        VStack {
            Text("\(utility.count)")
            Button("カウント") {
                utility.countUp()
            }
            
        }
        .padding()
    }
}

@Observableを付加したクラスを作成して変数と関数を作成したクラス内に移動します。
View内の監視では@Stateを付加しましたが、@Observableを付加したクラスのプロパティは監視対象となります。
参照する側のContentViewでは作成したクラスのインスタンスを入れる変数に@Stateを付加してインスタンスを作成します。

今回のサンプルの想定としてはViewごとに独立した値を管理する前提があるので、Viewでクラスのインスタンスを作成しています。もしView値で共通の値を持たせるのであれば、@Stateは付加せずContentViewではインスタンスは生成せず親からインスタンスを受け取るようにする必要があります。

HelloWorldApp.swift
@main
struct TestApp: App {
    var utility: Utility = Utility()
    
    var body: some Scene {
        WindowGroup {
            ContentView(utility: utility)
        }
    }
}
ContentView.swift
@Observable class Utility {
    var count: Int = 0
    
    func countUp() {
        count += 1
    }
}

struct ContentView: View {
    var utility: Utility
    
    var body: some View {
        VStack {
            Text("\(utility.count)")
            Button("カウント") {
                utility.countUp()
            }
            
        }
        .padding()
    }
}

データの永続化

上記までのサンプルではアプリケーションを終了すると保持していた値は消えました。
アプリケーション終了後も値を保持するためにデータを永続化する必要があります。

@AppStorageはSwiftUIらしい記載でUserDefaultsへ値を設定したり値を取得したりできるProperty Wrapperです。
@AppStorageを使用して直接設定/取得する場合は使い方は簡単なのですが、@Observableと併用する場合は@AppStorageを付加した変数は@ObservationIgnoredで監視対象外に設定して@AppStorageを付加した変数のアクセサを用意する必要があります。

ContentView.swift
@Observable class Utility {
    @ObservationIgnored @AppStorage("storageCount") var _count: Int = 0
    var count: Int {
        get {
            access(keyPath: \.count)
            return _count
        }
        set {
            withMutation(keyPath: \.count) {
                _count = newValue
            }
        }
    }
    
    func countUp() {
        count += 1
    }
}

struct ContentView: View {
    @State var utility: Utility = Utility()
    
    var body: some View {
        VStack {
            Text("\(utility.count)")
            Button("カウント") {
                utility.countUp()
            }
            
        }
        .padding()
    }
}

参考

Discussion