[Swift][SwiftUI] actor を使って、LifeGame を作る(その3: 進化とアニメーション)

SwiftUI2021

     
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 を作る)

今回

LifeGame として、進化させていきます。

・初期パターンを指定
・進化機能の実装
・アニメーション

初期パターン

格子模様から始めるのもありかもしれませんが、周期的にパターンを繰り返す有名どころのパターンがあるので、そこから始めてみようと思います。

ビーコンと呼ばれるパターンと、オクタゴンと呼ばれるパターンです。

Beacon
Octagone

パターンの Save/Load は、あとから考えるとして、以下のパターンをコードで作ってしまうことにします。
コード的には、setup のための closure を定義しました。

extension LGBoard {
    static let setupBlinker: BoardSetupClosure = { index in
        let liveCells = [CellIndex(x: 4, y: 2),
                         CellIndex(x: 4, y: 3),
                         CellIndex(x: 4, y: 4),
                         CellIndex(x: 4, y: 5),
                         CellIndex(x: 4, y: 6),
        ]
        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
    }
}

世代を進めるAPI

最初は、ボタンを押すごとに 1世代進むように実装していきます。

例によって、テストから考えます。

世代が進むことによって変化するパターンは、大きく以下の4パターンです。
・.alive -> .alive
・.alive -> .dead
・.dead -> .alive
・.dead -> .dead

LifeGame のルールを考慮して以下のようなテストを作りました。

LGBoard に対して、 doPrep で次世代状態を計算させて、moveToNextGen で実際に遷移させると言うメソッドを想定しています。
初期化時に、3×3 ボード状のセルを .alive/.dead に適切に設定し、(1,1) にある セルの次世代が期待する状態かどうかを確認しています。

    func test_proceedGeneration_deadTodead() async throws {
        let sut = LGBoard(numX: 3, numY: 3) { _ in return .dead }

        guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return  }
        XCTAssertEqual(current, .dead)
        await sut.doPrep()
        await sut.moveToNextGen()

        guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
        XCTAssertEqual(next, .dead)
    }

    func test_proceedGeneration_aliveTodead() async throws {
        let sut = LGBoard(numX: 3, numY: 3) { _ in return .alive }

        guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return  }
        XCTAssertEqual(current, .alive)
        await sut.doPrep()
        await sut.moveToNextGen()

        guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
        XCTAssertEqual(next, .dead)

    }

    func test_proceedGeneration_deadToalive() async throws {
        let sut = LGBoard(numX: 3, numY: 3) { cellIndex in
            let liveCells = [CellIndex(x: 0, y: 0),
                             CellIndex(x: 1, y: 0),
                             CellIndex(x: 2, y: 0),
            ]
            if liveCells.contains(cellIndex) { return .alive }
            return .dead
        }

        guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return  }
        XCTAssertEqual(current, .dead)
        await sut.doPrep()
        await sut.moveToNextGen()

        guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
        XCTAssertEqual(next, .alive)
    }
    func test_proceedGeneration_aliveToalive() async throws {
        let sut = LGBoard(numX: 3, numY: 3) { cellIndex in
            let liveCells = [CellIndex(x: 0, y: 0),
                             CellIndex(x: 1, y: 0),
                             CellIndex(x: 1, y: 1),
            ]
            if liveCells.contains(cellIndex) { return .alive }
            return .dead
        }

        guard let current = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
        XCTAssertEqual(current, .alive)
        await sut.doPrep()
        await sut.moveToNextGen()

        guard let next = await sut.status(CellIndex(x: 1, y: 1)) else { XCTFail("invalid state"); return }
        XCTAssertEqual(next, .alive)
    }

Note: 本来であれば、sut.status の返り値の optional チェックについては、XCTUnwrap で行いたいのですが、現時点では XCTUnwrap は、非同期に対応していないので、guard let でチェックしています。

テストはかけたので、このテストが通るように、LGBoard, LGCell を実装していきます。

同時並行的に LGCell の State を変更してしまうと不整合が発生してしまうので、計算は LGBoard 側で行うようにします。

ですので、LGCell への追加は単純なメソッドです。

LGCell の doPrep メソッドは、与えられた LGCellState を次の世代の State として nextState に保持します。

moveToNextGen は、nextState として保持されている状態を state に移すことで、状態を次世代のものに遷移させます。

    func doPrep(_ nextState: LGCellState) {
        self.nextState = nextState
    }

    func moveToNextGen() {
        guard let nextState = nextState else { fatalError() }
        self.state = nextState
        self.nextState = nil
    }

上記の LGCell のメソッドを使うように、LGBoard を実装します。

doPrep では、すべてのセルの周囲の状態を確認し、次の世代の状態を決定し、セルにセットします。

moveToNextGen では、すべてのセルを次の世代の状態に遷移させます。

    func doPrep() {
        for index in cells.keys {
            guard let cell = cells[index] else { continue }
            let nbrNumOfAlive = countAliveInNbrCells(center: index)
            let nextState: LGCellState
            switch cell.state {
            case .alive:
                if (nbrNumOfAlive == 2)||(nbrNumOfAlive == 3) {
                    nextState = .alive
                } else {
                    nextState = .dead
                }
            case .dead:
                if nbrNumOfAlive == 3 {
                    nextState = .alive
                } else {
                    nextState = .dead
                }
            }
            cell.doPrep(nextState)
        }
    }

    func moveToNextGen() {
        for key in cells.keys {
            cells[key]?.moveToNextGen()
        }
    }

周囲のセルの数をカウントする countAliveInNbrCells メソッドを作ってあったので、doPrep もシンプルな処理になっています。

上記実装で、テストをパスするようになります。

Refactoring

LGboard の doPrep の実装での if 文は、ルールに沿ってそのまま書いているのですが、少し冗長に見えるので、3項演算子を使って、以下のように書き直しました。

書き直した後もテストはパスするので、書き換えで問題が発生していないことも確認できます。

    func doPrep() {
        for index in cells.keys {
            guard let cell = cells[index] else { continue }
            let nbrNumOfAlive = countAliveInNbrCells(center: index)
            let nextState: LGCellState
            switch cell.state {
            case .alive:
                nextState = (nbrNumOfAlive == 2)||(nbrNumOfAlive == 3) ? .alive : .dead
            case .dead:
                nextState = nbrNumOfAlive == 3 ? .alive : .dead
            }
            cell.doPrep(nextState)
        }
    }

3項演算子は、以下の記事で説明してます。
Swift[Swift] 三項演算子の使い方

世代を進めるためのGUI

モデルを次世代へすすめることはできるようになりましたが、そのきっかけを外部から与える必要があります。

将来的には、タイマー等を使って、自動で世代が進むようにもしたいのですが、まずは、ボタンを使って、次世代へ進められるようにします。

ViewModel の修正

View からは、ViewModel 経由で Model にリクエストすることになりますので、ViewModel にも Model である LGBoard の API と同様のメソッドを追加します。
ここでは、実行の終了が確認できるように async なメソッドとして定義しています。

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

    // ....omit....

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

async と sync の使い分け

doPrep / moveToNextGen いずれも sync なメソッドとして定義して、メソッド内部で Task を使用して async なメソッドを呼ぶことも実装としては可能です。

ですが、LifeGame では、doPrep が完了したことを ViewModel 側からも検知できることが必要となります。
検知できないと、doPrep の終了を待たずに、moveToNextGen を呼び出してしまうかもしれません。(結果としては大抵の場合はうまく動作する気がしますが、競合が起こりえる状況になってしまいます)

ということで、doPrep/moveToNextGen のいずれも async なメソッドとして、ViewModel に追加しています。

Viewの修正

Model/ViewModel へは、必要なメソッドを追加しているので、あとは 次世代へ進めるためのボタンを追加すれば OK なハズです。
LGBoardView に Button を追加していきます。
Button を盤面の下側に表示するために、VStack を使いました。

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)
                            LGCellView(cellState: viewModel.cells[cellIndex] ?? .alive)
                        }
                    }
                }
            }
            Button(action: {
                Task {
                    await viewModel.doPrep()
                    await viewModel.moveToNextGen()
                    await viewModel.updateCells()
                }
            }, label: {Text("NextGen")})
        }
        .onAppear {
            Task {
                await viewModel.updateCells()
            }
        }
    }
}

以下のような外観になりました。

AppOverViewonMac

特に、macOS 限定の機能は使用していないので、(セルの大きさにだけ気をつければ) iOS 上でも同様に動作します。

AppOverViewOniOS

アニメーション

実際に動作させると、以下のようになります。

せっかく(?)なので、各セルが変更されるときに、カードが裏返るようなアニメーションを入れてみます。

LGCellViewのアニメーション対応

実際には、LGCelLView の修正が必要です。

現在の LGCellView は、与えられた状態に対応するイメージを表示するという実装です。

アニメーションさせるには、”何かが変更される”ということが必要となります。
アニメーションさせたい時は、LGCellView の持っている LGCellState が変更されているハズなので、その変更を契機にアニメーションさせていくことにします。

また、変更されるものを参照して どのようにアニメーションするかの設定も必要となります。
アニメーションについては、以下の記事でも使用している rotation3DEffect を使って、アニメーションさせていきます。
SwiftUI[SwiftUI] サイコロを振ってみる (SwiftUI アニメーションの練習)

cellState の変更を契機に アニメーションするように変更した LGCellView は、以下です。

struct LGCellView: View {
    let cellState: LGCellState
    var body: some View {
        Image(systemName: symbolName)
            .resizable().aspectRatio(contentMode: .fit)
            .frame(width: 50, height: 50)
            .rotation3DEffect(.degrees(cellState == .alive ? 0: 180), axis: (x: 0, y: -1, z: 0))
    }

    var symbolName: String {
        switch cellState {
        case .alive:
            return "circle"
        case .dead:
            return "xmark"
        }
    }
}

以下のようなアニメーションになります。(LGCellView 1つだけを表示するアプリで動作確認してます)

LGBoard でアニメーション

あとは、実際の CellState の変更を withAnimation で囲うとうまくいくはずなのですが、問題が発生します。

現在の実装で、CellState を変更しているのは、以下の箇所です。具体的には、”await viewModel.updateCells()” の行です。

            Button(action: {
                Task {
                    await viewModel.doPrep()
                    await viewModel.moveToNextGen()
                    await viewModel.updateCells()
                }
            }, label: {Text("NextGen")})

この箇所を withAnimation で囲おうとすると、Concurrency はサポートしていないと言うエラーになります・・・

ということで、別の方向からアニメーションを設定することが必要となります。

変数をチェックして View をアニメーションさせる ViewModifier (.animation) を使用します。

以下のように、LGCellView に cellState を引数として設定して付与することで、 LGCellView をアニメーションさせることができます。

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)
                        }
                    }
                }
            }
            Button(action: {
                Task {
                    await viewModel.doPrep()
                    await viewModel.moveToNextGen()
                    await viewModel.updateCells()
                }
            }, label: {Text("NextGen")})
        }
        .onAppear {
            Task {
                await viewModel.updateCells()
            }
        }
    }
}

.animation に渡すために、viewModel.cells[cellIndex] を、いったん 変数に受けています。

このようにすることで、以下のようにアニメーションしながら、表示が切り替わるようになります。

次回以降

少し遊んでいると、毎回、”NextGen” ボタンを押すのに疲れます。

次回は、指定した秒数ごとに 自動で進化させるような機能を実装してみます。

まとめ

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

LifeGame の モデルをアニメーション表示
  • Concurrency を使っていると、withAnimation 指定できないケースがある
  • .animation を使うと、監視する変数を指定してアニメーションできる

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

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版が最新版です。

コメントを残す

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