[SwiftUI] 15 Puzzle の作り方 (9: アニメーション手直し)

SwiftUI2021

     
⌛️ 2 min.
SwiftUI を使って、15パズルを作っていきます。
実際に遊んでみると Animation に違和感を感じるので修正します。

環境&対象

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

  • 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 を考慮しつつ、最大限コードを共通化します

全体のステップは、以下です。

15Puzzle とは

これから作っていくのは 15パズル です。スライドパズルと呼ばれることもあるようです。

Wikipedia での説明は、こちら

15puzzle

# 上記の画像も、Wikipedia から引用しています。

いわゆる ソリティア と呼ばれる 一人で遊ぶタイプのゲームの1つです。

違和感とその原因

アニメーション時間を 2秒にしていることもあり、素早く操作されると以下のような表示になってしまいます。

原因は、アニメーションが終わらないうちに 次の操作が行われ、そのために 複数のアニメーションが並行実行されているためです。

なお、当初 アニメーションをわかりやすくするために、”2秒間” のアニメーションとしていたのですが、実際に操作すると、2秒間は長く感じるので、もっと短くしたくなります。

違和感の解消

違和感の解消方法ですが、シンプルに 「操作によるアニメーション途中には、操作を受け付けない」 とします。

MEMO

別解としては、操作方法を キューに記録し、アニメーション終了ごとに順番に実行していく という方法も考えられるかと思います。

使用アニメーション変更

smooth アニメーションは、なんとなく 使っていたアニメーションだったので、interactiveSpring に切り替えます。

なお、定義をみるとわかるのですが、デフォルトの duration(アニメーション時間) は、0.15 秒のようです。

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)
                            .accessibilityIdentifier(String(panelNum))
                            .matchedGeometryEffect(id: panelNum, in: namespace)
                            .transition(.scale(1.0))
                            .onTapGesture {
                                if let movable = movable {
                                    withAnimation {
                                        viewModel.swap(Index2D(row, col), movable)
                                    }
                                }
                            }
                            .disabled(movable == nil)
                    }
                })
            }
        })
        // OLD
        //.animation(.smooth(duration:  2), value: viewModel.layout)
        // NEW
        .animation(.interactiveSpring, value: viewModel.layout)
    }
}

アニメーション終了まで操作を受け付けない

アニメーション途中であるかのフラグを作り、 アニメーション途中では、onTapGesture に反応しないようにします。

ViewModel にフラグ

フラグは、ViewModel に追加します。

class Puzzle15ViewModel: ObservableObject {
    var model: Puzzle15Model
    @Published var layout: Puzzle15Model.PanelLayout
    var cancellable: AnyCancellable? = nil
    @Published var showComplete = false
    @Published var underOperation = false  // true であればアニメーション中
    
    // ... omit ...
    
}

上記の ViewModel のフラグを使って、View の enable を制御します。

//
//  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)
                            .accessibilityIdentifier(String(panelNum))
                            .matchedGeometryEffect(id: panelNum, in: namespace)
                            .onTapGesture {
                                if let movable = movable {
                                    withAnimation {
                                        viewModel.swap(Index2D(row, col), movable)
                                    }
                                }
                            }
                            .disabled(movable == nil)
                            .disabled(viewModel.underOperation == true)    // !!! NEW !!!
                    }
                })
            }
        })
        .animation(.interactiveSpring, value: viewModel.layout)
    }
}

#Preview {
    Puzzle15BoardView()
}

こうすることで、(underOperation が正しく設定されば) アニメーション途中には、タップできなくなります。

アニメーション終了まで待つ

問題は、アニメーション終了の検知です。調べた範囲では、アニメーション終了時の call-back 等は無いようです。

ただ、自分でアニメーションを設定しているので、基本的に時間もわかるはずです。
ということで、自分で アニメーション時間分 待つことにしました。

ViewModel で時間を調整します。

class Puzzle15ViewModel: ObservableObject {
    // ... omit ...
    func swap(_ index1: Index2D,_ index2: Index2D) {
        self.underOperation = true
        Task {
            await model.swap(index1, index2)
            let state = await model.isComplete()
            if state {
                Task { @MainActor in
                    try? await Task.sleep(for: .seconds(1))
                    self.showComplete = state
                }
            }
        }
        Task { @MainActor in
            try? await Task.sleep(for: .seconds(0.2)) // アニメーション時間を待って、false に戻す (少し余裕をみてます)
            self.underOperation = false
        }
    }

手直し後

手直ししたアニメーションが以下です。

アニメーション途中で操作をできなくしているのですが、
そもそも アニメーション時間を短くしたために、アニメーション途中で 次の操作をすることが 難しくなってます。

# アニメーション時間を長くしてみると、その間 クリックできないことがわかります。

まとめ

アニメーションを調整しました

アニメーションを調整
  • 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版が最新版です。

コメントを残す

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