[Swift][SwiftUI] actor を使って、LifeGame を作る(その5:自動進化の速度設定)

SwiftUI2021

     
⌛️ 3 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: 進化とアニメーション)

・自動で進化する機能を追加した
SwiftUI2021 [Swift][SwiftUI] actor を使って、LifeGame を作る(その4: 複数の初期パターンと自動進化)

今回

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

・自動進化の速度を設定できるようにする

自動進化の速度設定

現在の設定は、2秒ごとに進化するようになっています。

この時間を設定できるようにしてみます。

仕様概要

SwiftUI では、値を設定するためのビューは色々と用意されていますが、ここでは、 Slider を使って設定できるようにします。

Slider 選択の理由は、以下です。
・具体的な数値に興味があるわけではない。
・操作しながら、どのくらいの間隔で進化するのかを理解しながら設定したい

例えば、Stepper という選択肢もありますが、今回の例では具体値を指定したいことはあまりないと考え 外しています。
特定の指定値から選択するのであれば、Picker という選択肢もあり得ますが、やはり 今回の例では適切ではないと考えました。

別途 設定画面を開いて設定することも可能ですが、どのくらいの速さで更新されるかをその場で確認したいと考えて、設定画面ではなく、表示画面内で設定できるようにしています。

値の設定範囲は、まずは、 0.1 ~ 3 秒としてみます。(操作してみて、調整の必要があれば 調整予定です)

実装

ViewModel の更新

前回までは、ViewModel 内で 以下のように定義していました。

let で定義されているため、nextGenInterval は、固定値でした。

前回まで

    let nextGenInterval: TimeInterval = 2

今回は、変更できるように かつ 変更によりビューが更新されるように、以下のように変更します。

    @Published var nextGenInterval: TimeInterval = 2
注意

厳密に考えると、タイマーから行われる変更により、ビューが更新されていくので、上記の @Published は 外しても、同様の動作になります。

View の更新

つぎに、Slider の配置です。

LGBoardView の “パターン選択” や “進化” ボタンの間、”自動進化”ボタンの横に配置します。

以下のような配置になります。

UIImage1st

※ スライダーので実行速度が設定できることがわかるように、SF Symbol の うさぎ と かめ を表示するようにしました。

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

    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")})
            .buttonStyle(.bordered)
            .disabled(viewModel.timerPubliser != nil)
            HStack {
                Button(action: {
                    viewModel.toggleAuto()
                }, label: {Text("NextGen(Auto)")
                        .hidden()
                        .overlay {
                            Text(viewModel.timerPubliser == nil ? "NextGen(Auto)" : "Stop" )
                        }
                })
                .buttonStyle(.bordered)
                HStack {
                    Image(systemName: "hare")
                    Slider(value: $viewModel.nextGenInterval,
                           in: 0.1...3,
                           label: { Text("Interval") })
                    Image(systemName: "tortoise")
                }
            }

            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
                }
            }
        }
    }
}

スライダーを追加したことで、LGViewModel のもつ nextGenInterval を UI を使って変更することができるようになりました。

次に、設定された値によって、自動進化の速度を変更されるよう 実装していきます。

自動進化は、自動進化ボタンを押下された時点での nextGenInterval を使った タイマーにより実行されています。

nextGenInterval が別の値になったときには、新しい nextGenInterval の値を使った タイマーを作成する必要があります。(実行途中のタイマーの周期を変更する方法はありません)

タイマーを管理しているのは、LGViewModel なので、LGViewModel に修正を入れていきます。
・(最新の)nextGenInterval を使って、タイマーを作成する (restartAuto メソッドと名付けます)
・nextGenInterval が更新されたときに restartAuto メソッドを実行する


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

    @Published var nextGenInterval: TimeInterval = 2 {
        didSet {
            // (1)
            self.restartAuto()
        }
    }
    // ... omit ...
    func restartAuto() {
        // (2)
        if timerPubliser == nil { return }
        // (3)
        timerPubliser = Timer.TimerPublisher(interval: nextGenInterval, runLoop: .current, mode: .default)
            .autoconnect()
            .sink(receiveValue: { newDate in
                Task {
                    await self.doPrep()
                    await self.moveToNextGen()
                    await self.updateCells()
                }
            })
    }
    // ... omit ...
}
コード解説
  1. nextGenInterval が設定されたときに restartAuto を実行する
  2. タイマーが実行されていなければ何もしない
  3. nextGenInterval の値でタイマーを再作成する

ここまでの実装で以下のようになります。

動いてはいますが、以下を改良したくなってきます。

・実行速度の数値を見たい
・実行速度が小さい値の時に、もう少しこまかく 実行速度を変更したい

実装を改良

以下の点の改良を行なっていきます。
・実行速度の数値を見たい
・小さい値の時に、もう少しこまかく 実行速度を変更したい

実行速度表示

実行速度の表示は、難しくありません。

LGViewModel が持っている nextGenInterval の値を表示してあげれば完成です。

LGViewModel に表示フォーマットを決める NumberFormatter を持たせ、String 化したデータを提供するメソッドを追加しました。

class LGViewModel: ObservableObject {
    // ... snip ...
    let numberFormatter: NumberFormatter = {
        let nf = NumberFormatter()
        nf.numberStyle = .decimal
        nf.minimumFractionDigits = 1
        nf.maximumFractionDigits = 1
        return nf
    }()

    var nextGenIntervalString: String {
        numberFormatter.string(from: nextGenInterval as NSNumber)!
    }
    // ... snip ...
}

LGBoardView では、Slider の横に、表示するようにしました。

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

    var body: some View {
        VStack {
            // ... snip ...
            HStack {
                // ... snip ...
                HStack {
                    Image(systemName: "hare")
                    // !! NEW !!
                    Text(viewModel.nextGenIntervalString)
                    Slider(value: $viewModel.nextGenInterval,
                           in: 0.1...3,
                           label: { Text("Interval") })
                    Image(systemName: "tortoise")
                }
            }
            // ... snip ...
        }
        // ... snip ...
    }
}

スライダーでの値設定を調整する

“小さい値の時に、もう少しこまかく 実行速度を変更したい” は、少し手間がかかります。

上記は、スライダーの移動量がどこでも同じではなく、小さい値では小さい量を表し、大きい値の付近では(相対的に)大きな量を表す ということです。

もう少し仕様っぽくしてみると、設定値を スライダーの位置と無関係に決めるのではなく、スライダーの位置に応じて、設定する ということになります。

SwiftUI のスライダーでは値は、開始値・終了値から常に線形に決められてきます。

そこで、以下のような仕様にします。

・Slider では、1…2.5 を設定する
・Slider で設定された値を x とすると 0.01 * 10^x を 速度の値として採用する

こうすることで、(左端の) 1 の時には 0.1 という値が、(右端の) 1.5 の時には、3.16 (程度)の値になるスライダーが完成します。その区間での値は、指数関数 のような上昇カーブを描きます。

MEMO

ここでは、なんとなく(?)実行速度を 0.1 ~ 3 くらいの間で変化させられたら良いかなと考えて上記の式を採用しています。
最終的な値域と 定義域を結びつけるような式を 作っただけでそれ以上の 意味はありません。

対数/log を使用した計算式にすることで、線形でない値の変化を作っています。

具体的なグラフは、以下のようになり、x が小さい値では x の変化量に対しての y の変化量は小さく、x が大きくなるに従い、x の変化量に対しての y の変化量は大きくなります。
グラフを見てわかる通り、x = 2.5 を超えたあたりから、変化量が大きすぎて このアプリでは 使えない気がします。

Log10

このような値設定をするためには、通常(?)の $ を使った Binding ではなく、自分で記述する必要があります。

以下は、スライダーの部分のみを抜き出しています。

        HStack {
            Image(systemName: "hare")
            Text(viewModel.nextGenIntervalString)
            Slider(value: Binding(get: {
                log10(viewModel.nextGenInterval / (0.01) )
            }, set: { newValue in
                viewModel.nextGenInterval = 0.01 * pow(10.0, newValue)
            }),
                   in: 1...2.5,
                   label: { Text("Interval") })
            Image(systemName: "tortoise")
        }

set では、上記の数式をそのまま実装していますし、get では逆関数を実装しています。

以下の動画をよく見ると、0.1 の付近(スライダー左端付近)では細かい幅で増減しますが、3.0 付近(スライダー右端付近)では、大きな値での増減がなされていることが 確認できます。

完成した App

最終的には、以下のような動作になっています。

次回以降

パターンを複数から選べるようになり、自動進化の時間も調整できるようになりました。

そろそろ、お決まりの(?) パターンに飽きてきたので、別パターンを試せるように改良していきます。

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

まとめ

LifeGame のモデル進化を自動実行させる機能の時間調整をできるようにしました。

LifeGame のモデル進化を自動実行させる機能の時間調整
  • Slider は線形に値を変化させる
  • Binding を実装することで、線形でない値変化をもつ Slider を作れる

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

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

コメントを残す

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