Sponsor Link
環境&対象
- macOS Sonoma Beta 7
- Xcode 15 Beta 8
- iOS 17 Beta 8
- Swift 5.9
# 特に ベータ版の機能を使用する予定はありません。
全体の方針
以下のような方針で、作っていきます。
– 全体のアーキテクチャは、MVVM(Model-View-ViewModel) です
– TDD で作っていきます。
– Model は、actor で作ります
– スライドパズルは、アニメーションして動作させます
– macOS/iOS それぞれの UI を考慮しつつ、最大限コードを共通化します
- Step1: 全体構想 / MVVM 責務分割
- Step2: View 設計・実装・テスト
- Step3: Model 設計・実装・テスト
- Step4: ViewModel 設計・実装・テスト
- Step5: View-ViewModel 接続
- Step6: アニメーション追加
- Step7: 配置ランダム化
- Step8: 完成判定
- Step9: Animation 手直し
15Puzzle とは
これから作っていくのは 15パズル です。スライドパズルと呼ばれることもあるようです。
Wikipedia での説明は、こちら
# 上記の画像も、Wikipedia から引用しています。
いわゆる ソリティア と呼ばれる 一人で遊ぶタイプのゲームの1つです。
空きパネル表示
アニメーションを追加する前に、現在 “0” で代用している 空きパネルの表示を修正します。
個別のパネルを表示しているのは、Puzzle15PanelView です。
“0” であるときには、Text を非表示にし、背景の矩形も clear 色にしています。
//
// Puzzle15PanelView.swift
//
// Created by : Tomoaki Yagishita on 2023/09/06
// © 2023 SmallDeskSoftware
//
import SwiftUI
struct Puzzle15PanelView: View {
let num: Int
var body: some View {
if num != 0 {
Text(String(num))
.font(.largeTitle)
.frame(width: 80, height: 80)
.background {
RoundedRectangle(cornerRadius: 5)
.fill(.green.opacity(0.75))
}
} else {
Text(String(num))
.font(.largeTitle)
.frame(width: 80, height: 80)
.opacity(0.0)
.background {
RoundedRectangle(cornerRadius: 5)
.fill(.clear)
}
}
}
}
#Preview {
Puzzle15PanelView(num: 15)
}
初期配置を確認すると、以下のような表示になります。
SwiftUI の identity を考えると、Text の background や opacity を num の値によって調整するのが妥当に思えるのですが、この後のアニメーションでは 空パネルは アニメーション対象外とする予定です。0 ~ 15 のパネルは移動する時にアニメーションしますが、空パネルは とくに何もアニメーションする予定はありません。
そのことを考慮して、ここでは if 文を使用して、View を切り替えています。
テストの更新
当然ですが、表示を変更すると snapshot テストしていた箇所が 通らなくなります。
なお、Puzzle15BoardView は、 EnvironmentObject として ViewModel を受け取るように変更していましたので、そもそも テストはパスしなくなっています・・・
EnvironmentObject に対応するのは、簡単です。.environmentObject 指定するだけです。
テストとしての比較対象の画像ファイルの更新は、プロジェクトフォルダ下に保存されている該当 png ファイルを削除してしまうのが簡単です。
snapshotTesting が新しく画像ファイルを 該当フォルダに作成してくれますので、その画像ファイルが妥当であるかを確認すれば OK です。
以下のようなテストに修正しました。(environmentObject 指定を追加しただけです)
//
// Puzzle15BoardView_Tests.swift
//
// Created by : Tomoaki Yagishita on 2023/09/06
// © 2023 SmallDeskSoftware
//
import XCTest
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
import SnapshotTesting
@testable import Puzzle15
@MainActor
final class Puzzle15BoardView_Tests: XCTestCase {
func test_Puzzle15BoardView() async throws {
let viewModel = Puzzle15ViewModel()
let view = Puzzle15BoardView().environmentObject(viewModel)
let vc = NSUIHostingController(rootView: view)
#if os(macOS)
XCTSkip("test not supported on macOS, test on iOS instead")
#else
vc.view.frame = UIScreen.main.bounds
#endif
assertSnapshot(of: vc, as: .image)
}
}
アニメーションの追加
現状でも、動作するという意味では OK ですが、せっかくなので スライドする時のアニメーションを追加していきます。
変更前に、現状の表示を確認してみます。
なんとなく、スライドしているように脳内補間されてしまいますが、表示が切り替わっているだけです。
.animation
SwiftUI でのアニメーションは、さまざまな方法が用意されていますがここでは、 View Modifier である animation を使用します。
Apple のドキュメントは、こちら。
1つ目の引数は、どのようなアニメーションかの指定
2つ目の引数は、どの変数の変化を機にアニメーションするかです。
//
// Puzzle15BoardView.swift
//
// Created by : Tomoaki Yagishita on 2023/09/06
// © 2023 SmallDeskSoftware
//
import SwiftUI
struct Puzzle15BoardView: View {
@EnvironmentObject var viewModel: Puzzle15ViewModel
var body: some View {
Grid(horizontalSpacing: 2, verticalSpacing: 2, content: {
ForEach(0...3, id: \.self) { row in
GridRow(content: {
let panels = viewModel.panelRows(for: row)
ForEach(0...3, id: \.self) { col in
let panelNum = panels[col].value
let movable = panels[col].movable
Puzzle15PanelView(num: panelNum)
.onTapGesture {
if let movable = movable {
viewModel.swap(Index2D(row, col), movable)
}
}
.disabled(movable == nil)
}
})
}
})
.animation(.smooth(duration: 2), value: viewModel.layout)
}
}
#Preview {
Puzzle15BoardView()
}
以下のようなアニメーションになります。
.smooth 指定しているので、空きパネルの位置には、smooth に新しいパネルが表示されるはずです。
そして、クリックしたパネルの位置は、.smooth に空きパネルになるハズです・・・が、ちょっと期待と異なります。
いずれにしても、この animation 指定では、(変更のあった)個々のパネルの数値が animation しているだけ です。つまり、クリックしたパネルが(現在 空きパネルの位置である) 移動先に徐々に現れて、クリックした箇所のパネルが徐々に消える というアニメーションしかできません。
.matchedGeometryEffect
今回が まさしくそのケースですが、表示されている要素が移動するようなアニメーションをつけたい時があります。
その時に有用な View Modifier が .matchedGeometryEffect と .transition です。
matchedGeometryEffect についての Apple のドキュメントは、こちら。
transition についての Apple のドキュメントは、こちら。
//
// Puzzle15BoardView.swift
//
// Created by : Tomoaki Yagishita on 2023/09/06
// © 2023 SmallDeskSoftware
//
import SwiftUI
struct Puzzle15BoardView: View {
@EnvironmentObject var viewModel: Puzzle15ViewModel
@Namespace var namespace
var body: some View {
Grid(horizontalSpacing: 2, verticalSpacing: 2, content: {
ForEach(0...3, id: \.self) { row in
GridRow(content: {
let panels = viewModel.panelRows(for: row)
ForEach(0...3, id: \.self) { col in
let panelNum = panels[col].value
let movable = panels[col].movable
Puzzle15PanelView(num: panelNum)
.matchedGeometryEffect(id: panelNum, in: namespace)
.transition(.scale(scale: 1.0))
.onTapGesture {
if let movable = movable {
viewModel.swap(Index2D(row, col), movable)
}
}
.disabled(movable == nil)
}
})
}
})
.animation(.smooth(duration: 2), value: viewModel.layout)
}
}
#Preview {
Puzzle15BoardView()
}
これらの View Modifier を指定することで、(アニメーション前後での) View の対応づけを指定することができます。
結果として、以下のようなスライドのアニメーションになります。
まとめ
15Puzzle に アニメーションを追加しました
- .animation でアニメーションの契機となる変数を指定する
- matchedGeometryEffect を使うと、アニメーション時の図形を対応づけられる
- .animation に nil を渡すとアニメーションしなくなる
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
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版が最新版です。
Sponsor Link