[Swift][SwiftUI] actor を使って、LifeGame を作る(その4: 複数の初期パターンと自動進化)

SwiftUI2021

     
⌛️ 4 min.
LifeGame を作っていく過程で、SwiftUI / Swift を理解していきます。actor と MVVM の組み合わせを確認していきます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13.2 Beta
  • Xcode 14.2

全てを作成してから記事を書いていないので、途中でバージョンアップがあるかもしれません。

LifeGame

LifeGame とは以下のようなルールのもと 世代を進めていき、進化を眺める(?)ゲームです。

・セルは、「生きている」「死んでいる」の2つの状態のいずれかを持つ。
・セルは、時間が進むにつれて、「死んだり」「生きたり」する
・セルは、周囲の8つのセルの状態に応じて、「死んだり」「生きたり」する

・セルの誕生死滅ルール
・生きているセルの周りに、2つ もしくは 3つの生きたセルがあると、そのセルは 次の世代でも生きている
・生きているセルの周りに、1つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代で死滅する
・死んでいるセルの周りにちょうど3つの生きたセルがあると、そのセルに次の世代で誕生する
・死んでいるセルの周りに、2つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代でも死滅したまま

# セルの”周りのセル”とは、周囲8個のセルのことです。
詳細は、Wikipediaで。

前回まで

前回までに行ったことは以下のことです。

・ボード全体を表現する LGBoard を actor で 作成した
・各セルを表現する LGCell を class で作成した
・LGBoard 内での隣接セルの状態を確認する準備をした
[Swift][SwiftUI] actor を使って、LifeGame を作る(その1: actor でモデルを作る)

・ViewModel として LGViewModel を作成した
・LGBoard を表示するための LGBoardView を作成した
・LGCell を表示するための LGCellView を作成した
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その 2: ViewModel/View を作る)

・進化ロジックを実装した
・LGViewCell をアニメーション対応にした
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その3: 進化とアニメーション)

今回

以下の機能を追加してみます。

・自動で進化を行う
・いくつかのパターンを選択できる

自動で進化する

NextGen ボタンを繰り返しクリックしなくても、進化を眺めていられるように、自動で進化する機能をつけてみます。

設計概要

特定の時間ごとに、(自動で)進化していくようにしてみます。

ボタンを押すと、自動で進化し始めるとして、必要な情報は 以下です。
・何秒ごとに進化するか
-> とりあえず(?) 2秒固定とします。
・指定時間ごとに進化させるのはだれか?(MVVM のどれ?)
-> ViewModel で管理することとします。

実装

(大したことを決めていませんが、)おおよそ設計できたので進めていきます。

最初に課題となるのが、テストです。(UI以外は、最初にテストを書いて進めていきたいです)

「指定時間ごとに、進化する」をまるごとテストしようとすると難しいです。

「指定時間ごとに、進化する」 = 「指定時間ごとに進化するメソッドをコールする」+「きちんと進化する」

後半の「きちんと進化する」は、すでに書いているので、テストされています。

残りは、「指定時間ごとに進化するメソッドをコールする」です。これは実は、Timer.TimerPublisher がまるごと引き受けてくれる機能です。
アプリのコードが行うことは、正しい引数でメソッドをコールすることです。(厳密にはもう少しあります)

つまり、Apple が提供してくれるライブラリがそのまま引き受けてくれる機能なので、テスト不要です。

Apple のコードをテストする必要はないと言うことです。

アプリの振る舞いをテストするのであれば、アプリのUIを通した振る舞いのテストを行うことになります。

ということで、テストは書かずに実装していきます。

ViewModel

実装する先は、LGViewModel です。

指定時間ように、TimeInterval 型の変数を定義します。(最初は、2秒固定で実装してます)
タイマーの実装は、Timer.TimerPublisher を使います。
[Combine] Combine の Publisher を改めて理解する

外部からのメソッドコールで、自動進化を ON/OFF できるようにします。
TimerPublisher を sink した cancellable を ViewModel に保持し、OFF のときには、nil にすることで、タイマーを停止させるようにします。

class LGViewModel: ObservableObject {
    var board: LGBoard
    @Published var cells: [CellIndex:LGCellState] = [:]

    // NEW !!
    let nextGenInterval: TimeInterval = 2 // 2 sec.
    @Published var timerPubliser: AnyCancellable? = nil

    init(numX: Int, numY: Int,_ closure: @escaping BoardSetupClosure) {
        board = LGBoard(numX: numX, numY: numY, closure)
    }

    @MainActor
    func updateCells() async {
        cells = await board.boardStatus()
    }

    var numX: Int {
        board.numX
    }
    var numY: Int {
        board.numY
    }

    func doPrep() async {
        await board.doPrep()
    }
    func moveToNextGen() async {
        await board.moveToNextGen()
    }

    // NEW !!
    func toggleAuto() {
        if timerPubliser == nil {
            // start
            timerPubliser = Timer.TimerPublisher(interval: nextGenInterval, runLoop: .current, mode: .default)
                .autoconnect()
                .sink(receiveValue: { newDate in
                    Task {
                        await self.doPrep()
                        await self.moveToNextGen()
                        await self.updateCells()
                    }
                })
        } else {
            // stop
            timerPubliser = nil
        }
    }
}

View

自動進化させるためのボタンを追加する必要があります。

自動実行中に、”NextGen” ボタンを押されないようにもします。

自動実行中かどうかは、LGViewModel の TimerPublisher からの cancellable を保持している変数が nil であるかどうかで判断できます。
nil 以外であれば、”NextGen” ボタンを disable にするようにします。

自動実行ボタンも、自動実行中には “Stop” ボタンになり、自動実行を停止できるようにします。

struct LGBoardView: View {
    @EnvironmentObject var viewModel: LGViewModel

    var body: some View {
        VStack {
            Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                ForEach(0..<viewModel.numY, id: \.self) { yIndex in
                    GridRow {
                        ForEach(0..<viewModel.numX, id: \.self) { xIndex in
                            let cellIndex = CellIndex(x: xIndex, y: yIndex)
                            let cellState = viewModel.cells[cellIndex] ?? .alive
                            LGCellView(cellState: cellState)
                                .animation(.default, value: cellState)
                        }
                    }
                }
            }
            HStack {
                Button(action: {
                    Task {
                        await viewModel.doPrep()
                        await viewModel.moveToNextGen()
                        await viewModel.updateCells()
                    }
                }, label: {Text("NextGen")})
                .buttonStyle(.bordered)
                .disabled(viewModel.timerPubliser != nil)
                Button(action: {
                    viewModel.toggleAuto()
                }, label: {Text("NextGen(Auto)")
                        .hidden()
                        .overlay {
                            Text(viewModel.timerPubliser == nil ? "NextGen(Auto)" : "Stop" )
                        }
                })
                .buttonStyle(.bordered)
            }
        }
        .onAppear {
            Task {
                await viewModel.updateCells()
            }
        }
    }
}

ボタンのラベル文字列を変更しても同じサイズで表示する方法は、以下の記事で説明している方法を使っています。
SwiftUI [SwiftUI] 表示要素を同じサイズにする方法

実装結果

以下のような動作をするアプリになりました。

初期パターンを選択する

現在の実装では、初期パターンを変更するにはコードを書き換えて再コンパイルが必要です。

さすがに 不便 なので、いくつかのパターンを記憶させておき、リストから選択できるようにしてみます。

設計概要

設計としては、以下としてみます。
・あらかじめ用意してあるパターンをプルダウンリストから選択できる

用意するパターン

以下の3パターンを用意してみます。

BlinkerPattern
BlinkerPattern
BeaconPattern
BeaconPattern
OctagonPattern
OctagonPattern

実装

実装自体は、Picker を使って実装します。

Picker の selection の変化に応じて、LGViewModel 経由で LGModel を変更するようにします。

class LGViewModel: ObservableObject {
    // .. omit ..
    func modelSetup(_ closure: BoardSetupClosure ) async {
        await board.setup(closure)
        await updateCells()
    }
    // .. omit ..
}

LGBoard が受け取るのと同様に、セットアップのための closure を受け取ることとしました。

MEMO

実コードとして closure を渡していますが、名称を渡して、LGModel 内部で変換するようにもできます。
どちらが良いということではなく、今回はこのような方法で実装したと言うことです。

次に、LGBoardView の修正です。

struct LGBoardView: View {
    @EnvironmentObject var viewModel: LGViewModel
    @State private var selectedPattern: Int = 0

    var body: some View {
        VStack {
            Grid(horizontalSpacing: 0, verticalSpacing: 0) {
                // .. snip .. cell 表示
            }
            HStack {
                // .. snip .. ボタン表示
            }
            // 🆕
            Picker(selection: $selectedPattern, content: {
                Text("Blinker").tag(0)
                Text("Beacon").tag(1)
                Text("Octagon").tag(2)
            }, label: {
                Text("select pattern")
            })
            .disabled(viewModel.timerPubliser != nil)
        }
        .onAppear { Task { await viewModel.updateCells() } }
        // 🆕
        .onChange(of: selectedPattern) { pattern in
            Task {
                switch selectedPattern {
                case 0:
                    await viewModel.modelSetup(LGBoard.setupBlinker)
                case 1:
                    await viewModel.modelSetup(LGBoard.setupBeacon)
                case 2:
                    await viewModel.modelSetup(LGBoard.setupOctagon)
                default:
                    break
                }
            }
        }
    }
}

LGBoard に用意した セットアップ用の closure は、以下のように定義しています。

extension LGBoard {
    static let setupBlinker: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 3, y: 3),
                         CellIndex(x: 3, y: 4),
                         CellIndex(x: 3, y: 5),
        ]
        if liveCells.contains(index) { return .alive }
        return .dead
    }
    static let setupBeacon: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 2, y: 2), CellIndex(x: 3, y: 2),
                         CellIndex(x: 2, y: 3), CellIndex(x: 3, y: 3),
                         CellIndex(x: 4, y: 4), CellIndex(x: 5, y: 4),
                         CellIndex(x: 4, y: 5), CellIndex(x: 5, y: 5),
        ]
        if liveCells.contains(index) { return .alive }
        return .dead
    }
    static let setupOctagon: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 3, y: 0), CellIndex(x: 4, y: 0),
                         CellIndex(x: 2, y: 1), CellIndex(x: 5, y: 1),
                         CellIndex(x: 1, y: 2), CellIndex(x: 6, y: 2),
                         CellIndex(x: 0, y: 3), CellIndex(x: 7, y: 3),
                         CellIndex(x: 0, y: 4), CellIndex(x: 7, y: 4),
                         CellIndex(x: 1, y: 5), CellIndex(x: 6, y: 5),
                         CellIndex(x: 2, y: 6), CellIndex(x: 5, y: 6),
                         CellIndex(x: 3, y: 7), CellIndex(x: 4, y: 7),

        ]
        if liveCells.contains(index) { return .alive }
        return .dead
    }
}

ここまででできたアプリ

ここまでの実装で以下のようなアプリになっています。

・ボタン押下による LifeGame の進化
・タイマーによる LifeGame の自動進化
・プルダウンからの初期パターン選択

次回以降

パターンを複数から選べるようになり、すこし(?) 遊べるようになりましたが、さまざまなパターンで動かしてみたくなったときに、少し困ります。

次回は、GUI 上で、パターンを作成する機能と 作成したパターンの保存機能を作ってみます。

まとめ

LifeGame のモデルを進化させる機能を実装し、アニメーション表示できるようにしました。

LifeGame の モデルをアニメーション表示
  • 時間での実行は、Timer.TimerPublisher を使うと、簡単に実装できる
  • 状態に応じて、disabled すると ユーザーにわかりやすいハズ

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です