Sponsor Link
環境&対象
- macOS Ventura 13.2
- 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 を作成した
[Swift][SwiftUI] actor を使って、LifeGame を作る(その 2: ViewModel/View を作る)
・進化ロジックを実装した
・LGViewCell をアニメーション対応にした
[Swift][SwiftUI] actor を使って、LifeGame を作る(その3: 進化とアニメーション)
・自動で進化する機能を追加した
[Swift][SwiftUI] actor を使って、LifeGame を作る(その4: 複数の初期パターンと自動進化)
・自動進化の速度を設定する機能を追加した
[Swift][SwiftUI] actor を使って、LifeGame を作る(その5:自動進化の速度設定)
・パターンを自分で作る機能(タップで、.alive/.dead 調整)
・パターンを保存、復元する機能(Save/Load)
[Swift][SwiftUI] actor を使って、LifeGame を作る(その6:パターン作成・保存機能)
今回やること
前回までで 機能的には完成していましたが、以下の点を改善して、終わりにします。
・モデルからの更新通知を受けて、ビューを更新する
以前は、開発者視点で、ビューを更新するタイミングがわかっていたので、必要に応じて、ビューを更新するメソッドを呼び出していました。
現在の機能は、シンプルなので、更新するタイミングを把握することは難しくありませんが、”ボードが複数になり、更新する対象が増えたとき”、”変更するタイミングが複数あるとき” 等が増えてくると、適切なタイミングで更新するメソッドを呼び出すことがどんどん難しくなっていきます。
仕様概要
やりたいことは、モデルの更新を受けて、ビューのための ViewModel を更新する ということです。
モデルの更新は、モデル内部で変更する時に、通知することとします。
macOS/iOS では、通知の手段として、NotificationCenter もありますが、今回は、Combine を使って通知することにします。
SwiftUI では、ビュー更新のアニメーション処理を考慮して、objectWillChange という名称がついた “これから変更する” という Publisher を使用していますが、この記事では、”objectDidChange” という名称の Publisher を使用します。
SwiftUI は、ビュー更新のサイクルがあり、その更新サイクルとの関係が重要になりますが、通常のモデル操作では そのようなサイクルはありません。ですので、確実に変更が行われた後の objectDidChange という通知の方が便利だと思います。
実装を見据えた仕様詳細
以下のような仕様で実装してきます。
・Combine の PassthroughSubject を使って通知を実装する
・通知内容は、更新後のボードの状態を [CellIndex: LGCellState] という型で渡す。
・変更が行われたときは、ボード全体について上記の情報を渡す
実装
LGBoard が変更を通知するので、変更の主対象は LGBoard となります。
そのほかに、通知を受けて ViewModel を更新するために、LGViewModel も変更の対象となります。
LGBoard の修正
まずは、LGBoard に、PassthroughSubject をプロパティとして追加します。
typealias BoardStatusDic = [CellIndex: LGCellState]
actor LGBoard: ObservableObject {
nonisolated let numX: Int
nonisolated let numY: Int
nonisolated
let objectDidChange = PassthroughSubject<BoardStatusDic, Never>()
// ...omit...
}
上記のように、objectDidChange プロパティを追加し、外部から参照できるようにします。
nonisolated を付与しておくことで、(非同期ではなく)同期的に参照できるようになります。(実際に、let で定義されたプロパティですので、生成後に変更されることはありませんので、データ競合が起こることもありません)
次に、LGBoard を変更する箇所で、PassthroughSubject 経由で変更を通知します。
actor LGBoard: ObservableObject {
// ...omit...
func setup(_ closure: BoardSetupClosure = { index in .alive }) async {
for x in 0..<numX {
for y in 0..<numY {
let index = CellIndex(x: x, y: y)
let state = closure(index)
let cell = LGCell(state: state)
cells[index] = cell
}
}
let status = await boardStatus()
objectDidChange.send(status)
}
func toggleCell(_ index: CellIndex) async {
cells[index]?.toggle()
let status = await boardStatus()
objectDidChange.send(status)
}
func moveToNextGen() async {
for key in cells.keys {
cells[key]?.moveToNextGen()
}
let status = await boardStatus()
objectDidChange.send(status)
}
// ...omit...
}
実際に(表示に影響を与える)変更が行われる箇所は、toggleCell と moveToNextGen メソッドなので、そのメソッド内で変更が行われた後に、PassthroughSubject を使って、変更を通知しています。
LGViewModel の修正
次に、通知を受けて、ViewModel を更新するように、LGViewModel を修正します。
class LGViewModel: ObservableObject {
var board: LGBoard
@Published var cells: [CellIndex:LGCellState] = [:]
@Published var savedBoards: [(String,BoardData)] = []
var cancellable: AnyCancellable? = nil
init(numX: Int, numY: Int,_ closure: @escaping BoardSetupClosure) {
board = LGBoard(numX: numX, numY: numY, closure)
cancellable = board.objectDidChange
.sink { newStatus in
Task {@MainActor in
self.cells = newStatus
}
}
}
func modelSetup(_ boardData: BoardData) async {
board = LGBoard(boardData)
cancellable = board.objectDidChange
.sink { newStatus in
Task { @MainActor in
self.cells = newStatus
}
}
}
// ... omit ...
}
board の objectDidChange を sink して、変更通知を受け取ります。
変更通知には、board の状態が入っていますので、そのまま ViewModel 中の cells の更新に使用します。
このようにすることで、以前の updateCells で行っていた更新処理が不要になります。
注意すべきは、modelSetup で、BoardData を受け取るケースでは、LGBoard 自体を再作成しているので、sink をやり直す点です。
class LGViewModel: ObservableObject {
// ... snip ...
func modelSetup(_ closure: BoardSetupClosure ) async {
await board.setup(closure)
// await updateCells()
}
func modelSetup(_ boardData: BoardData) async {
board = LGBoard(boardData)
// await updateCells()
}
// @MainActor
// func updateCells() async {
// cells = await board.boardStatus()
// }
func toggleCell(_ index: CellIndex) async {
await board.toggleCell(index)
// await updateCells()
}
func restartAuto() {
if timerPubliser == nil { return }
timerPubliser = Timer.TimerPublisher(interval: nextGenInterval, runLoop: .current, mode: .default)
.autoconnect()
.sink(receiveValue: { newDate in
Task {
await self.doPrep()
await self.moveToNextGen()
// await self.updateCells()
}
})
}
func toggleAuto() {
if timerPubliser == nil {
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 {
timerPubliser = nil
}
}
}
以前は、updateCells を明示的に各所から呼び出していましたが、不要となります。
# 上記のコードはわかりやすさのために、不必要になった箇所を コメントアウトしています。
LGBoardView の修正
以下の点を修正します。
・起動時に、初期選択のパターン(Blinker)を Model にセットする
struct LGBoardView: View {
@EnvironmentObject var viewModel: LGViewModel
@State private var selectedPattern: String = "Blinker"
// ... snip ...
var body: some View {
VStack {
// ... snip ...
}
.onAppear {
Task {
await viewModel.modelSetup(LGBoard.setupBlinker)
}
}
}
}
アプリ最終版
今回、モデルの変更通知を受けて、ViewModel が自分でアップデートするように修正しました。
まとめ
LifeGame を作りました。以下は、これまでの記事でのまとめのまとめです。
- 並行アクセスでデータ競合が発生しそうであれば、actor を使うことを検討する
- actor 内のプロパティは、actor の context からでないと同期的にアクセスできない
- データ競合が発生しないとわかっているプロパティは、nonisolated 指定することで、actor context 外からでも同期的にアクセスできるよう指定できる
- actor へのアクセスは非同期なので、View 向けには 同期アクセスできるデータが必要となる
- 盤状の要素配置は、 Grid/GridLayout が便利
- Concurrency を使っていると、withAnimation 指定できないケースがある
- .animation を使うと、監視する変数を指定してアニメーションできる
- 時間での実行は、Timer.TimerPublisher を使うと、簡単に実装できる
- 状態に応じて、disabled すると ユーザーにわかりやすいハズ
- Slider は線形に値を変化させる
- Binding を実装することで、線形でない値変化をもつ Slider を作れる
- Model の変更を subscribe して ViewModel を更新するには、Combine を使う
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
SwiftUI おすすめ本
SwiftUI を理解するには、以下の本がおすすめです。
SwiftUI ViewMatery
SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。
英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。
View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。
超便利です
販売元のページは、こちらです。
SwiftUI 徹底入門
# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。
Swift学習におすすめの本
詳解Swift
Swift の学習には、詳解 Swift という書籍が、おすすめです。
著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。
最新版を購入するのがおすすめです。
現時点では、上記の Swift 5 に対応した第5版が最新版です。
最終的なコード
パターンを複数から選べるようになり、自動進化の時間も調整できるようになりました。
そろそろ、お決まりの(?) パターンに飽きてきたので、別パターンを試せるように改良していきます。
次回は、GUI 上で、パターンを作成する機能と 作成したパターンの保存機能を作ってみます。
LifeGame2022App.swift
//
// LifeGame2022App.swift
//
// Created by : Tomoaki Yagishita on 2022/11/11
// © 2022 SmallDeskSoftware
//
import SwiftUI
@main
struct LifeGame2022App: App {
@StateObject var viewModel: LGViewModel = LGViewModel(numX: 8, numY: 8, LGBoard.setupBlinker)
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(viewModel)
}
}
}
LGBoardView.swift
//
// LGBoardView.swift
//
// Created by : Tomoaki Yagishita on 2023/01/10
// © 2023 SmallDeskSoftware
//
import SwiftUI
struct LGBoardView: View {
@EnvironmentObject var viewModel: LGViewModel
@State private var selectedPattern: String = "Blinker"
@State private var showSheet: Bool = false
@State private var patternName: String = ""
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)
.onTapGesture {
Task {
await viewModel.toggleCell(cellIndex)
}
}
}
}
}
}
Button(action: {
Task {
await viewModel.doPrep()
await viewModel.moveToNextGen()
}
}, 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")
Text(viewModel.nextGenIntervalString)
Slider(value: Binding<Double>(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")
}
}
HStack {
Picker(selection: $selectedPattern, content: {
Text("Blinker").tag("Blinker")
Text("Beacon").tag("Beacon")
Text("Octagon").tag("Octagon")
ForEach(viewModel.savedBoards, id: \.0) { item in
Text(item.0)
.tag(item.0)
}
}, label: {
Text("select pattern")
})
.fixedSize()
.disabled(viewModel.timerPubliser != nil)
Button(action: {
showSheet.toggle()
}, label: {Text("Save")})
Button(action: {
viewModel.remove(selectedPattern)
selectedPattern = "Blinker"
}, label: { Image(systemName: "trash") })
Button(action: {
Task {
await viewModel.modelSetup({ _ in
.dead
})
}
}, label: { Text("Clear") })
}
}
.onAppear {
Task {
await viewModel.modelSetup(LGBoard.setupBlinker)
}
}
.onChange(of: selectedPattern) { name in
Task {
switch name {
case "Blinker":
await viewModel.modelSetup(LGBoard.setupBlinker)
case "Beacon":
await viewModel.modelSetup(LGBoard.setupBeacon)
case "Octagon":
await viewModel.modelSetup(LGBoard.setupOctagon)
default:
if let data = viewModel.savedBoards.first(where: {$0.0 == name}) {
await viewModel.modelSetup(data.1)
}
}
}
}
.sheet(isPresented: $showSheet) {
VStack {
TextField(text: $patternName, label: { Text("Pattern Name: ") })
.textFieldStyle(.roundedBorder)
.padding()
HStack {
Button(action: {
guard patternName != "" else { return }
Task {
await viewModel.save(patternName)
selectedPattern = patternName
patternName = ""
}
showSheet.toggle()
}, label: { Text("OK") }).disabled(patternName=="")
Button(action: { showSheet.toggle() }, label: { Text("Cancel") })
}
}
.presentationDetents([.fraction(0.3)])
.padding()
}
}
}
struct LGBoardView_Previews: PreviewProvider {
static var previews: some View {
LGBoardView()
}
}
LGCellView.swift
//
// LGCellView.swift
//
// Created by : Tomoaki Yagishita on 2023/01/10
// © 2023 SmallDeskSoftware
//
import SwiftUI
struct LGCellView: View {
let cellState: LGCellState
var body: some View {
Image(systemName: symbolName)
.resizable().aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.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"
}
}
}
struct LGCellView_Previews: PreviewProvider {
static var previews: some View {
HStack {
LGCellView(cellState: .alive)
LGCellView(cellState: .dead)
}
}
}
LGViewModel.swift
//
// LGViewModel.swift
//
// Created by : Tomoaki Yagishita on 2023/01/10
// © 2023 SmallDeskSoftware
//
import Combine
import SwiftUI
class LGViewModel: ObservableObject {
var board: LGBoard
@Published var cells: [CellIndex:LGCellState] = [:]
@Published var savedBoards: [(String,BoardData)] = []
var cancellable: AnyCancellable? = nil
@Published var nextGenInterval: TimeInterval = 2 {
didSet {
self.restartAuto()
}
}
@Published var timerPubliser: AnyCancellable? = nil
init(numX: Int, numY: Int,_ closure: @escaping BoardSetupClosure) {
board = LGBoard(numX: numX, numY: numY, closure)
cancellable = board.objectDidChange
.sink { newStatus in
Task { @MainActor in
self.cells = newStatus
}
}
}
func modelSetup(_ closure: BoardSetupClosure ) async {
await board.setup(closure)
}
func modelSetup(_ boardData: BoardData) async {
board = LGBoard(boardData)
cancellable = board.objectDidChange
.sink { newStatus in
Task { @MainActor in
self.cells = newStatus
}
}
}
func toggleCell(_ index: CellIndex) async {
await board.toggleCell(index)
}
var numX: Int {
board.numX
}
var numY: Int {
board.numY
}
func doPrep() async {
await board.doPrep()
}
func moveToNextGen() async {
await board.moveToNextGen()
}
let numberFormatter: NumberFormatter = {
let nf = NumberFormatter()
nf.numberStyle = .decimal
nf.minimumFractionDigits = 2
nf.maximumFractionDigits = 2
return nf
}()
var nextGenIntervalString: String {
numberFormatter.string(from: nextGenInterval as NSNumber)!
}
func restartAuto() {
timerPubliser = Timer.TimerPublisher(interval: nextGenInterval, runLoop: .current, mode: .default)
.autoconnect()
.sink(receiveValue: { newDate in
Task {
await self.doPrep()
await self.moveToNextGen()
}
})
}
func toggleAuto() {
if timerPubliser == nil {
restartAuto()
} else {
timerPubliser = nil
}
}
}
extension LGViewModel {
@MainActor
func save(_ name: String) async {
let boardData = await board.boardData()
self.savedBoards.append((name, boardData))
}
@MainActor
func remove(_ name: String) {
if let index = savedBoards.firstIndex(where: {$0.0 == name}) {
savedBoards.remove(at: index)
}
}
}
LGBoard.swift
//
// LGBoard.swift
//
// Created by : Tomoaki Yagishita on 2022/11/11
// © 2022 SmallDeskSoftware
//
import Foundation
import Distributed
enum LGCellState: CustomStringConvertible, Codable {
case alive, dead
var description: String {
switch self {
case .alive:
return "o"
case .dead:
return "x"
}
}
mutating func toggle() {
self = (self == .alive) ? .dead : .alive
}
}
class LGCell: Codable {
private(set) var state: LGCellState
private(set) var nextState: LGCellState?
init(state: LGCellState) {
self.state = state
self.nextState = nil
}
func toggle() {
state.toggle()
}
func doPrep(_ nextState: LGCellState) {
self.nextState = nextState
}
func moveToNextGen() {
guard let nextState = nextState else { fatalError() }
self.state = nextState
self.nextState = nil
}
}
struct CellIndex: Hashable, Codable {
let x: Int
let y: Int
var north : CellIndex { CellIndex(x: x-1, y: y ) }
var northWest: CellIndex { CellIndex(x: x-1, y: y-1) }
var west: CellIndex { CellIndex(x: x , y: y-1) }
var southWest: CellIndex { CellIndex(x: x+1, y: y-1) }
var south : CellIndex { CellIndex(x: x+1, y: y ) }
var southEast: CellIndex { CellIndex(x: x+1, y: y+1) }
var east: CellIndex { CellIndex(x: x , y: y+1) }
var northEast: CellIndex { CellIndex(x: x-1, y: y+1) }
var nbrIndexes:[CellIndex] {
[north, northWest, west, southWest,
south, southEast, east, northEast]
}
}
extension CellIndex: CustomStringConvertible {
var description: String {
return String("(\(x),\(y))")
}
}
typealias BoardSetupClosure = (CellIndex) -> LGCellState
struct BoardData: Codable {
let numX: Int
let numY: Int
let cells:[CellIndex: LGCellState]
}
actor LGBoard: ObservableObject {
nonisolated let numX: Int
nonisolated let numY: Int
var cells:Dictionary = [:]
init(numX: Int, numY: Int,_ closure: BoardSetupClosure = { index in .alive }) {
self.numX = numX
self.numY = numY
for x in 0.. LGCellState? {
return cells[index]?.state
}
func toggleCell(_ index: CellIndex) {
cells[index]?.toggle()
}
func boardStatus() async -> [CellIndex: LGCellState] {
var newDic: [CellIndex: LGCellState] = [:]
for key in cells.keys {
if let cell = cells[key] {
newDic[key] = cell.state
}
}
return newDic
}
func boardData() async -> BoardData {
let cellStatus = await self.boardStatus()
return BoardData(numX: numX, numY: numY, cells: cellStatus)
}
func countAliveInNbrCells(center: CellIndex) -> Int {
var numOfAlive = 0
for index in center.nbrIndexes {
guard let cell = cells[index] else { continue }
if cell.state == .alive {
numOfAlive += 1
}
}
return numOfAlive
}
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)
}
}
func moveToNextGen() {
for key in cells.keys {
cells[key]?.moveToNextGen()
}
}
}
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
}
}
Sponsor Link