[SwiftUI] 15Puzzle の作り方(2: View 設計・実装・テスト)

SwiftUI2021

     
⌛️ 5 min.
SwiftUI を使って、15パズルを作っていきます。

環境&対象

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

  • 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つです。

View 構想

前回、全体的な MVVM の責務分割を行ったので、View をもう少し詳細まで確認します。

View に期待されることは、15パズルの パネルを表示すること、パネルへの操作を受け付けること、各種ボタンの操作を受け付けること です。

View の構造

アプリの View は、通常複数の View を組み合わせて作成します。

15パズルアプリでは、View の 名前として、Puzzle15AppView としましたが、これは、一番親となる View の名前です。
Puzzle15AppView の配下の View 構成を考えてみます。

Puzzle15AppView
|—- Puzzle15BoardView
| | —- Puzzle15PanelView
| | —- Puzzle15PanelView
| | ……
| | —- Puzzle15PanelView
| | —- Puzzle15PanelView
| | —- Puzzle15PanelView
| | —- Puzzle15PanelView
| | —- Puzzle15PanelView
| | —- Puzzle15EmptyView
|
|—- Button: 配置シャッフル用

Puzzle15PanelView は、1枚のパネルを表示するビューです。
Puzzle15EmptyView は、パネルの空き部分を表示するビューです。(必要性が微妙なので、当面 番号 0 を持つ Puzzle15PanelView で 代用していきます。必要性が明確になった時点で再検討します)
Puzzle15BoardView は、15枚の Puzzle15PanelView と Puzzle15EmptyView 1枚を レイアウトして表示するためのビューです。

Button は、配置シャッフル機能を使用するために、Puzzle15AppView 内の Puzzle15BoardView の上下いずれかに配置する予定です。

Puzzle15AppView

Puzzle15AppView では、構成要素を上から下に並べれば良さそうなので、VStack を使用してレイアウトします。

以下のようなイメージです。

VStack {
   Puzzle15BoardView()
   Button("シャッフル")
}

なお、Button は、標準で用意されている Button を使用します。

Puzzle15BoardView

Puzzle15BoardView は、子要素に、Puzzle15PanelView と Puzzle15EmptyView を持つビューです。

15パズルでは、15個の Puzzle15PanelView と 1個の Puzzle15EmptyView を表示します。

表示する時のレイアウトは、方眼状です。
そのようなレイアウトを行うために、SwiftUI では Grid レイアウトが用意されているので Grid を使用して配置します。

Grid は、内部的には、行を縦方向に積み重ねていく配置になります。Grid 内部の行は、GridRow という要素で指定します。

つまり、初期状態の15パズルの状態を表示するためには、以下のようになります。

Grid(content: {
   GridRow(content: {
       Puzzle15PanelView(1)
       Puzzle15PanelView(2)
       Puzzle15PanelView(3)
       Puzzle15PanelView(4)
   })
   GridRow(content: {
       Puzzle15PanelView(5)
       Puzzle15PanelView(6)
       Puzzle15PanelView(7)
       Puzzle15PanelView(8)
   })
   GridRow(content: {
       Puzzle15PanelView(9)
       Puzzle15PanelView(10)
       Puzzle15PanelView(11)
       Puzzle15PanelView(12)
   })
   GridRow(content: {
       Puzzle15PanelView(13)
       Puzzle15PanelView(14)
       Puzzle15PanelView(15)
       Puzzle15EmptyView()
   })
})

Grid の使い方は、以下の記事で説明してます。
SwiftUI2021 [SwiftUI] Grid の使い方

Puzzle15PanelView

Puzzle15PanelView は、1枚のパネルを表示するビューです。

パネルには、1~15を表示します。
パネルは、表示する情報(1~15)以外は同じであるため 数字ごとに個別に作らずに
どの数字を表示するかを外部から受け取ることで、同じビューを利用できるようにします。

Project作成

イメージで作業するのが辛くなってきたので、実際に作っていきます。

まずは、Xcode で Project を作成します。

1. “File”-“New”-“Project…” と選びます。
2. プロジェクトの Template は、”Multiplatform” – “App” を選んで “Next”
3. Product Name に 適当な名前( Puzzle15 としました)を設定、Storage は、”None”を選択、”Host in CloudKit” は 未チェック、”Include Tests” にはチェックを入れて “Next”
4. どこか保存したい場所を選び、”Create Git repository on my Mac” にチェックを入れて、”Create”

Xcode の refactoring 機能を使用して、ContentView を Puzzle15AppView に rename しておきます。

Puzzle15PanelView 作成とテスト

View を作っていきます。末端の View から作っていくと作りやすいです。
このアプリでは、1つの数字のパネルを表す Puzzle15PanelView が末端の View です。

ファイル作成

まずは、ファイルを作成します。

1. “File” – “New” – “File…” で “User Interface” セクションの “SwiftUI View” を選択し、”Next”
2. 名前を指定して( Puzzle15PanelView としました)、保存先と Target を選んで、”Create”

なお、1. の操作で “SwiftUI View” を選択すると Hello, World のテンプレートと #Preview が入ったファイルが作成されます。”Swift” を選ぶと import Foundation されているだけのファイルが作成されます。

Puzzle15PanelView

数字を どのように表示するかは、デザインセンスの見せ所なのですが、ここでは、シンプルにしました。

数字をテキストで表示し、バックグラウンドに矩形を表示します。

つまり、以下のように表示されるということです。

Panel

折角(?) なので矩形の角に小さなRを入れました。

//
//  Puzzle15PanelView.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/06
//  © 2023  SmallDeskSoftware
//

import SwiftUI

struct Puzzle15PanelView: View {
    let num: Int
    var body: some View {
        Text(String(num))
            .font(.largeTitle)
            .frame(width: 80, height: 80)
            .background {
                RoundedRectangle(cornerRadius: 5)
                    .fill(.green.opacity(0.75))
            }
    }
}

#Preview {
    Puzzle15PanelView(num: 15)
}

View(Puzzle15PanelView) のテスト

おおよその View ができたので、テストを書きます。

Snapshot-Testing

View のテストには、Snapshot-Testing という外部モジュールを使用します。

使用するモジュールは、こちら

このモジュールを使用することで、View が表示するものを “絵” 的に比較することが可能となります。

# このモジュール自体は、View を “絵” 的に比較するだけでなくさまざまなテストが可能です。

Swift Package に対応しているので、簡単にプロジェクトに追加できます。

MEMO

テストに使用するので、追加する時のターゲットは、アプリではありません。

UnitTest で使用するので、UnitTest を追加先のターゲットとして指定することが必要です。
# UITest ではありません。

MEMO その2

macOS アプリは、sandbox 設定があるために、簡単にテスト中の スクリーンショットを保存することができません。
この記事では、snapshot-testing は、iOS のテストで使用することにします。

Puzzle15PanelView のテスト

1〜15の値を与えた時に 与えた数値をラベルとして 表示しているか確認します。

//
//  Puzzle15PanelView_Tests.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/06
//  © 2023  SmallDeskSoftware
//

import XCTest
import SwiftUI
#if os(macOS)
import AppKit
typealias NSUIView = NSView
typealias NSUIHostingController = NSHostingController
#else
import UIKit
typealias NSUIView = UIView
typealias NSUIHostingController = UIHostingController
#endif
import SnapshotTesting

@testable import Puzzle15

@MainActor
final class Puzzle15PanelView_Tests: XCTestCase {
    func test_Puzzle15PanelView_number() async throws {
        for num in 1...15 {
            let view = Puzzle15PanelView(num: num)
            let vc = NSUIHostingController(rootView: view)
            #if os(macOS)
            #else
            vc.view.frame = UIScreen.main.bounds
            #endif

            assertSnapshot(of: vc, as: .image)
        }
    }
}

# SnapshotTesting は、SwiftUI View を直接サポートしてはいないので、UIHostingController で wrap して使用しています
# macOS へ対応しようとした形跡が 中途半端に残ってます・・・無視してください

なお、テスト1回目の実行では “No reference was found on disk.” というエラーで fail します。

テストフォルダ配下に、”__Snapshots__” というフォルダが作成され、さらに テストクラスごとにサブフォルダが作成され、その中に、スナップショットが保存されています。

snapshot_structure

例えば、1 に対しては、以下のイメージが保存されています。

screenshotFor1

ここで、1〜15それぞれに、適切なイメージが作成されていることを確認します。

2回目以降は、これらのイメージとの比較が行われます。そして同一であれば “テスト通過” とみなされ、差分があれば、”テスト失敗”となります。

なお、UIHostingController は、MainActor で実行されることを想定しているので、テストクラス自体に MainActor 指定を付加しています。

Puzzle15BoardView 作成とテスト

Puzzle15PanelView の時と同様にファイルを作成します。

Grid を使って配置します。
Grid の各行は、GridRow を使うことで指定できます。

今は、初期配置を表示するために、すべて ハードコーディングしてしまいます。

//
//  Puzzle15BoardView.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/06
//  © 2023  SmallDeskSoftware
//

import SwiftUI

struct Puzzle15BoardView: View {
    var body: some View {
        Grid(horizontalSpacing: 1, verticalSpacing: 2, content: {
            ForEach(1...4, id: \.self) { row in
                GridRow(content: {
                    ForEach(1...4, id: \.self) { col in
                        let num = (row-1) * 4 + col
                        if num < 16 {
                            Puzzle15PanelView(num: num)
                        } else {
                            Puzzle15PanelView(num: 0)
                        }
                    }
                })
            }
        })
    }
}

配置してみるとわかりますが、各パネルの隙間が大きすぎても小さすぎても自然な配置に見えないので、試行錯誤して、Grid の horizontalSpacing, verticalSpacing 設定に共に 2 を指定しています。

以下のような表示になります。

BoardView

Puzzle15BoardView のテスト

App には、Board は、1つしか表示されません。

Board については、テストとしては以下が考えられます。
1) 15枚+1枚のパネルが指定位置に表示されるか
2) タッチ操作すると、適切なパネルの表示が適切に更新されるか
3) シャッフルすると、パネルの位置が適切に更新されるか

2), 3) については、UITest でテストすることにします。

ここでは、1) についてテストします。

本来であれば、さまざまな配置について 適切な位置に表示されていることをテストすべきですが、このタイミングでは、パネルの配置情報をどのように Model/ ViewModel/ View で共有するかすら決めていません。

ですので、このタイミングでは、初期位置を想定して、1〜15 および 空パネルを意味する 0 が 想定される位置に表示されていることを確認することとします。

ViewModel 等の詳細を決めたタイミングで、このテストはアップデートすることを検討します。

//
//  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_number() async throws {
        let view = Puzzle15BoardView()
        let vc = NSUIHostingController(rootView: view)
        #if os(macOS)
        #else
        vc.view.frame = UIScreen.main.bounds
        #endif
        assertSnapshot(of: vc, as: .image)
    }

}

実際には、以下のようなビューになっています。

boardViewTestResult

Puzzle15AppView の作成とテスト

Puzzle15AppView は、最初に ContentView を rename したものです。

まずは、VStack を使って、Puzzle15BoardView と Button を配置します。

Button には、UITest からアクセスできるように accessibilityIdentifier 設定をしておきます。

現時点では、SwiftUI の VStack のようなレイアウト要素については UITest からうまくアクセスすることはできないので、AccessibilityIdentifier を Puzzle15BoardView に 設定することはしません。

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/06
//  © 2023  SmallDeskSoftware
//

import SwiftUI

struct Puzzle15AppView: View {
    var body: some View {
        VStack {
            Puzzle15BoardView()
            Button(action: {}, label: {
                Text("Shuffle")
            })
            .accessibilityIdentifier("ShuffleButton")
        }
        .padding()
    }
}

#Preview {
    Puzzle15AppView()
}

以下のような画面となっています。

AppImage_Initial

Puzzle15AppViewのテスト

Puzzle15AppView(Puzzle15App) に対しては、View のテストではなく、ユーザー操作を模擬するテスト UITest を行う予定です。
ですが 現時点では何も実装していないので、何も動作しません。

UITest が動作するか確認するという意味もあり、とりあえず(?)、起動したあとに、Shuffle ボタンが表示されて、操作可能であることを確認しておきます。

//
//  Puzzle15UITests.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/06
//  © 2023  SmallDeskSoftware
//

import XCTest

final class Puzzle15UITests: XCTestCase {
    func testExample() throws {
        let app = XCUIApplication()
        app.launch()

        let shuffleButton = app.buttons["ShuffleButton"]
        XCTAssertEqual(shuffleButton.exists, true)
        XCTAssertEqual(shuffleButton.isEnabled, true)
    }
}

まとめ

15Puzzle 向け View とテストを作成しました。

15Puzzle 向け View とテストを作成
  • Grid は、グリッドに沿って配置するレイアウト
  • Grid は行を積み重ねるレイアウトで、各行は GridRow で指定する
  • View の表示テストの選択肢の1つに Snapshot-testing がある
  • SwiftUI の Container View は、UITest での対象にはできない

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

コメントを残す

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