[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1)

ボーリングアプリ(スコアを記録するアプリ)を、SwiftUI と TDD で作ってみます。その1で作ったモデルをビューに表示していきます
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)

どんなモデルを作ったかは、上記記事を参照ください。

アプリ設計:振り返り

これまでのところ、前回作った設計に沿って作ってきています。

今回作る、View と ViewModel の箇所を改めて抜粋します。

アプリ設計 View と ViewModel
  • MVVM の M (Model) : 前回記事を参照のこと
  • MVVM の VM (ViewModel)
    • Frame に記録されている1投目が 10 であれば、ストライク表示を行う
    • Frame に記録されている2投の合計が 10 であれば、スペア表示を行う
    • スコアが、-1 (計算不可)の時には、"-" がスコアとして表示される
  • MVVM の View (View)
    • Frame 表示には、FrameView を作る
    • FrameView は、BowlView x 2 + ScoreView で構成される
    • GameView は、FrameView を10と、TotalScoreViewを2つ持つ
    • GameView は、ViewModel を持つ

View を作る

ビューを作っていきます。
テンプレートとして作られている Content に GameView が表示されるように作っていきます。

GameView, FrameView, TotalScoreView, BowlView, ScoreView と複数のビューを作るので、新しい Swift ファイルを追加して、これらのビューを定義していきます。

トップのビューの名前を使って、GameView.swift を追加します。

作る前に気づいたことがあるので、少し整理

以下の不足に気づきました。

  • 各フレームのフレーム番号を表すビューが不足していることに気づいたので、FrameIndexView としました。
  • スコアを入力する手段がないので、電卓のような0〜9のボタンを並べることにしました。クリックすることで、入力されます。9つのボタンもつビューを、InputView としました。

全体の整合性も考えて、以下のようなビュー名にしました。

  • 全体のビュー : BowlingGameView
  • フレームを表示するビュー: FrameView
  • フレームの中でインデックス表示するビュー:FrameIndexView
  • フレームの中で1、2投目を表示するビュー:FrameBowlView
  • フレームの中でスコアを表示するビュー:FrameScoreView
  • 一番右側で、合計スコアを表示するビュー: TotalScoreView
  • 倒したピン数を入力するためのビュー:InputView

View を作る

まずは、それぞれのビューで枠だけ表示するものを作りました。

# 途中のレイアウト試行錯誤で、Portrait 表示は諦めて、Landscape(いわゆる横画面)で作ることにしました。

Views code

//
//  BowlingGameView.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import SwiftUI

//(1)
struct BowlingGameView: View {
    var body: some View {
        VStack(spacing:0) {
            HStack(spacing: 0) {
                // (2)
                Group {
                    // (3)
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                    FrameView()
                }
                // (4)
                TotalScoreView()
            }
            // (5)
            InputView()
        }
    }
}

// (6)
struct FrameView: View {
    var body: some View {
        VStack(spacing:0) {
            // (7)
            FrameIndexView()
            HStack(spacing: 0) {
                // (8)
                FrameBowlView()
                FrameBowlView()
            }
            // (9)
            FrameScoreView()
        }
    }
}

// (10)
struct FrameIndexView: View {
    var body: some View {
        Text("1")
            .frame(width: 50, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

// (11)
struct FrameBowlView: View {
    var body: some View {
        Text("3")
            .frame(width: 25, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

// (12)
struct FrameScoreView: View {
    var body: some View {
        Text("100")
            .frame(width: 50, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

// (13)
struct TotalScoreView: View {
    var body: some View {
        VStack {
            Text("Total")
                .frame(width: 50, height: 20)
                .border(Color.gray.opacity(0.5))
            Text("154")
                .frame(width: 50, height: 40)
                .border(Color.gray.opacity(0.5))
        }
    }
}

// (14)
struct InputView: View {
    var body: some View {
        HStack {
            Button(action: {}, label: { Text("0") } )
            Button(action: {}, label: { Text("1") } )
            Button(action: {}, label: { Text("2") } )
            Button(action: {}, label: { Text("3") } )
            Button(action: {}, label: { Text("4") } )
            Button(action: {}, label: { Text("5") } )
            Button(action: {}, label: { Text("6") } )
            Button(action: {}, label: { Text("7") } )
            Button(action: {}, label: { Text("8") } )
            Button(action: {}, label: { Text("9") } )
        }
        .font(.largeTitle)
    }
}

struct BowlingGameView_Previews: PreviewProvider {
    static var previews: some View {
        // (15)
        BowlingGameView()
            .previewLayout(.fixed(width: 1792/2, height: 828/2))
    }
}
コード解説
  1. 全体表示用のビュー
  2. ViewBuilder は、10までしか子要素を持てないので、Group でまとめてます
  3. FrameView を力技で 10 並べました
  4. HStack の一番右に、TotalScoreView が表示されます
  5. ボーリングのスコア表示の下に、InputView を表示します
  6. 1フレームを表示するビュー
  7. フレームのインデックスを一番上に表示します。
  8. インデックスの下に、投球結果のビューを横に並べて表示します
  9. インデックス、投球結果の下に、そのフレームまでの合計スコアを表示します
  10. フレームのインデックスを表示するビュー
  11. フレームでの投球表示用のビュー
  12. 各フレームでのスコア表示用のビュー
  13. 合計スコア表示用のビューです。上 1/3に Total と表示して、下 2/3 を使って、スコア表示します
  14. ボタンを10個並べておきました。後からレイアウト含め調整予定
  15. Landscape でプレビューしたかったので、previewLayout に iPhone11 のサイズを指定してます

とりあえず、全体のバランスをみるために、ダミーデータを表示するようにして作りました。見た目は、以下のような感じです。

BowlingGameApp Mock

「BowlingGameApp Mock」

ViewModel を作って、Model と View をつなぐ

シンプルに、Model を保持するクラスを作ります。名前は、BowlingGameViewModel とします。

以下を考慮して、作りました。

  • ObservableObject を継承する(変化した時に、ビューをアップデートして欲しいため)
  • InputView は、ボタンを押された時にモデルを変更するため、@ObservedObject 指定で、ViewModel を受け取る。他のビューは、変更しないので、普通に受け取る。
  • 各フレームでの投球結果表示用に、ViewModel に フレームインデックスと投球インデックスを引数に、表示用の文字列を返す関数を作る
  • 各フレームでのスコア表示用に、ViewModel に フレームインデックスを引数として、そこまでのスコアを返す関数を作る

して、以下のようになりました。

BowlingGameViewModel code

//
//  BowlingGameViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import Foundation
import SwiftUI

class BowlingGameViewModel : ObservableObject{
    // (1)
    @Published var game: BowlingGame
    // (2)
    init() {
        game = BowlingGame()
    }
    // (3)
    func bowlAsText(frame:Int, bowl: Int) -> String {
        if let num = game.bowlResult(frame: frame, bowl: bowl) {
            return String(num)
        }
        return "-"
    }
    // (3)
    func scoreAsText(frame:Int) -> String {
        return "0"
    }
}
コード解説
  1. モデルとして作った BowlingGame を持ちます。変化が起こった時にビューを更新するので、@Published を指定しています。
  2. 初期化時に、BowlingGame を作成します。(ViewModel 初期化時に、Model が初期化されるということです。)
  3. フレームの投球結果を取得する関数を作り、Model と接続しています。結果を文字列で返すことで、View 側で加工しやすくしています。
  4. フレーム時点でのスコアを返す関数を作り、ダミー実装を入れています。

上記の ViewModel の実装に合わせて、先ほど作った View に ViewModel への参照を追加しました。

長いですが、ざっくりいうと、以下のように手を入れました。

  • FrameView, FrameBowlView, FrameScoreView, TotalScoreView は、BowlingGame の更新に伴い更新されるので、@ObservedObject として BowlingGameViewModel を保持します
  • FrameView, FrameBowlView, FrameScoreView で、インデックスを保持し、自分が何フレーム目、何投目であるかをわかるようにしています
  • View からは、ViewModel の持つ関数を呼んでいるだけで、ロジック等は入れていません
  • フレームのインデックス番号は、内部では 0開始 ですが、人間的には 1開始 が普通なので、表示する時に、オフセットして表示してます
  • ゲームのトータルスコアは、とりあえず、10フレームでのスコア計算結果を表示していますが、そのうち変更しないといけません
BowlingGameView code

//
//  BowlingGameView.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import SwiftUI

struct BowlingGameView: View {
    @StateObject var viewModel: BowlingGameViewModel = BowlingGameViewModel()
    
    var body: some View {
        VStack(spacing:0) {
            HStack(spacing: 0) {
                Group {
                    FrameView(viewModel: viewModel, index: 0)
                    FrameView(viewModel: viewModel, index: 1)
                    FrameView(viewModel: viewModel, index: 2)
                    FrameView(viewModel: viewModel, index: 3)
                    FrameView(viewModel: viewModel, index: 4)
                    FrameView(viewModel: viewModel, index: 5)
                    FrameView(viewModel: viewModel, index: 6)
                    FrameView(viewModel: viewModel, index: 7)
                    FrameView(viewModel: viewModel, index: 8)
                    FrameView(viewModel: viewModel, index: 9)
                }
                TotalScoreView(viewModel: viewModel)
            }
            InputView(viewModel: viewModel)
        }
    }
}

struct FrameView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    let index:Int
    var body: some View {
        VStack(spacing:0) {
            FrameIndexView(index: index)
            HStack(spacing: 0) {
                FrameBowlView(viewModel: viewModel, frameIndex: index, bowlIndex: 0)
                FrameBowlView(viewModel: viewModel, frameIndex: index, bowlIndex: 1)
            }
            FrameScoreView(viewModel: viewModel, frameIndex: index)
        }
    }
}

struct FrameIndexView: View {
    let index:Int
    var body: some View {
        Text(String(index+1))
            .frame(width: 50, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

struct FrameBowlView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    let frameIndex: Int
    let bowlIndex: Int

    var body: some View {
        Text(viewModel.bowlAsText(frame: frameIndex, bowl: bowlIndex))
            .frame(width: 25, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

struct FrameScoreView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    let frameIndex: Int
    var body: some View {
        Text(viewModel.scoreAsText(frame: frameIndex))
            .frame(width: 50, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

struct TotalScoreView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    var body: some View {
        VStack {
            Text("Total")
                .frame(width: 50, height: 20)
                .border(Color.gray.opacity(0.5))
            Text(viewModel.scoreAsText(frame: 9))
                .frame(width: 50, height: 40)
                .border(Color.gray.opacity(0.5))
        }
    }
}

struct InputView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    var body: some View {
        HStack {
            Button(action: {}, label: { Text("0") } )
            Button(action: {}, label: { Text("1") } )
            Button(action: {}, label: { Text("2") } )
            Button(action: {}, label: { Text("3") } )
            Button(action: {}, label: { Text("4") } )
            Button(action: {}, label: { Text("5") } )
            Button(action: {}, label: { Text("6") } )
            Button(action: {}, label: { Text("7") } )
            Button(action: {}, label: { Text("8") } )
            Button(action: {}, label: { Text("9") } )
        }
        .font(.largeTitle)
    }
}

struct BowlingGameView_Previews: PreviewProvider {
    static var previews: some View {
        BowlingGameView()
            .previewLayout(.fixed(width: 1792/2, height: 828/2))
    }
}

上記のコードで、以下のような表示となります。

BowingGameApp Mock 2

「BowingGameApp Mock 2」

すこし Refactoring

View と ViewModel を繋げて気づきますが、View に繰り返しのコードが多すぎです。

特に、BowlingGameView の FrameView とか、InputView の Button が気になるので、修正します。

BowlingGameView と InputView を以下のように変更し、繰り返しのコードを減らしました。

BowlingGameView 抜粋 code

struct BowlingGameView: View {
    @StateObject var viewModel: BowlingGameViewModel = BowlingGameViewModel()
    
    var body: some View {
        VStack(spacing:0) {
            HStack(spacing: 0) {
                Group {
                    ForEach (0..<10) { index in
                        FrameView(viewModel: viewModel, index: index)
                    }
                }
                TotalScoreView(viewModel: viewModel)
            }
            InputView(viewModel: viewModel)
        }
    }
}
......
struct InputView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    var body: some View {
        HStack {
            ForEach(0..<10) { index in
                Button(action: {}, label: { Text(String(index)) } )
            }
        }
        .font(.largeTitle)
    }
}
MEMO
コードを変更したり追加する時に、少しづつ違う変更や少しづつ違うコードを書くことになったら、Refactoring するタイミングかもしれません。

UITest を追加

コードが見やすくなってスッキリしたところで、すこしテストを書きましょう。View も実装したので、UITest を書きます。

「アプリを起動して、1のボタンを押したら、1フレームの最初の投球のところに1が表示されるかをテスト」というテストを作ります。

テストに向けた準備

以下の記事でも説明していますが、UITest をする際には、Accessibility ID を設定しておくことがポイントです。

[SwiftUI] [UnitTest] テストの作り方 ( Button 編)

BowlingGameView でも テスト対象とする要素に Accessibility ID を設定しましょう。

今回は、InputView の Button と FrameBowlView の Text に設定しましょう。

以下のように、.accessibility modifier を使うことで設定できます。

Accessibility ID 設定 code

 Button(action: {}, label: { Text("Button") } )
    .accessibility(identifier: "AccessibilityID")

Button と Text に、以下のように、.accessibility を設定します。

FrameBowlView に .accessiblity 追加

struct FrameBowlView: View {
    let viewModel: BowlingGameViewModel
    let frameIndex: Int
    let bowlIndex: Int

    var body: some View {
        Text(viewModel.bowlAsText(frame: frameIndex, bowl: bowlIndex))
            .accessibility(identifier: String("FrameBowlView\(frameIndex)-\(bowlIndex)"))
            .frame(width: 25, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}
InputView に .accessiblity 追加

struct InputView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    var body: some View {
        HStack {
            ForEach(0..<10) { index in
                Button(action: {}, label: { Text(String(index)) } )
                    .accessibility(identifier: String("Button\(index)"))
            }
        }
        .font(.largeTitle)
    }
}

FrameBowlView には、FrameBowlView0-1 のような ID を付与し、Button には、Button0 のような IDを付与しました。

アプリを操作するテストを作成

「アプリを起動して、1のボタンを押したら、1フレームの最初の投球のところに1が表示されるかをテスト」というテストを作りたいので、テストコードとしては以下のようになります。

アプリを起動して、1のボタンを押したら、1フレームの最初の投球のところに1が表示されるかをテスト

    func test_RecordOneBowl_FirstBowl_OnlyOneShouldBeRecorded() throws {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()
        // (1)
        let button1 = app.buttons["Button1"]
        XCTAssertTrue(button1.exists)
        // (2)
        let frame0bowl0Label = app.staticTexts["FrameBowlView0-0"]
        XCTAssertTrue(frame0bowl0Label.exists)
        // (3)
        XCTAssertEqual(frame0bowl0Label.label, "-")
        // (4)
        button1.tap()
        // (5)
        XCTAssertEqual(frame0bowl0Label.label, "1")
    }
コード解説
  1. 1 のボタンを押すために、Button1 という ID を持つボタンを取得します。(存在をテストしています)
  2. 1 フレームの 第1投の Text である FrameBowl0-0 という ID を持つテキストを取得します。(存在をテストしています)
  3. 投球前には、FrameBowl0-0 というテキストは、”-”を表示していることをテスト
  4. Button1 をタップ
  5. FrameBowl0-0 のテキストが、"1" に変わっていることをテスト

テストを実行

テストを作ったので実行してみます。

現時点では、1のボタンを押されても何もしませんので、表示が更新されず、(5) のテストで失敗します。

テスト結果:XCTAssertEqual failed: ("-") is not equal to ("1")

テストを成功するように修正

Button の action が空ですので、実装していきます。

1を押されたら、BowlingGame に1を記録。2〜9も同様に、押された数字を記録するようにします。

ViewModel が、BowlingGame モデルを持っていますので、View -> ViewModel -> Model という形で、伝えていきます。

ViewModel に追加

class BowlingGameViewModel : ObservableObject{
    @Published var game: BowlingGame
    // ... snip ...
    func addBowlResult(num: Int) {
        _ = game.addBowlResult(num)
    }
}
InputView で Button に action を追加

struct InputView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    var body: some View {
        HStack {
            ForEach(0..<10) { index in
                Button(action: {
                    viewModel.addBowlResult(num: index)
                }, label: { Text(String(index)) } )
                    .accessibility(identifier: String("Button\(index)"))
            }
        }
        .font(.largeTitle)
    }
}

こうすることで、押された数字が、スコアに記録されていくのを確認できます。

スコアを記録

「スコアを記録」

先ほど作成したテストも通ることが確認できます。

まとめ その2

ボタンクリックだけですが、UI が追加されたのでアプリケーションぽくなってきました。

まだ スペアやストライクの処理はおろか、スコア計算も行なっていません。次回は、スコア計算を作っていきます。

説明は以上です。次回に続きます。Happy Coding!

コメントを残す

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