[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)
どんなモデルを作ったかは、上記記事を参照ください。
Sponsor Link
アプリ設計:振り返り
これまでのところ、前回作った設計に沿って作ってきています。
今回作る、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(いわゆる横画面)で作ることにしました。
//
// 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))
}
}
- 全体表示用のビュー
- ViewBuilder は、10までしか子要素を持てないので、Group でまとめてます
- FrameView を力技で 10 並べました
- HStack の一番右に、TotalScoreView が表示されます
- ボーリングのスコア表示の下に、InputView を表示します
- 1フレームを表示するビュー
- フレームのインデックスを一番上に表示します。
- インデックスの下に、投球結果のビューを横に並べて表示します
- インデックス、投球結果の下に、そのフレームまでの合計スコアを表示します
- フレームのインデックスを表示するビュー
- フレームでの投球表示用のビュー
- 各フレームでのスコア表示用のビュー
- 合計スコア表示用のビューです。上 1/3に Total と表示して、下 2/3 を使って、スコア表示します
- ボタンを10個並べておきました。後からレイアウト含め調整予定
- Landscape でプレビューしたかったので、previewLayout に iPhone11 のサイズを指定してます
とりあえず、全体のバランスをみるために、ダミーデータを表示するようにして作りました。見た目は、以下のような感じです。
ViewModel を作って、Model と View をつなぐ
シンプルに、Model を保持するクラスを作ります。名前は、BowlingGameViewModel とします。
以下を考慮して、作りました。
- ObservableObject を継承する(変化した時に、ビューをアップデートして欲しいため)
- InputView は、ボタンを押された時にモデルを変更するため、@ObservedObject 指定で、ViewModel を受け取る。他のビューは、変更しないので、普通に受け取る。
- 各フレームでの投球結果表示用に、ViewModel に フレームインデックスと投球インデックスを引数に、表示用の文字列を返す関数を作る
- 各フレームでのスコア表示用に、ViewModel に フレームインデックスを引数として、そこまでのスコアを返す関数を作る
して、以下のようになりました。
//
// 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"
}
}
- モデルとして作った BowlingGame を持ちます。変化が起こった時にビューを更新するので、@Published を指定しています。
- 初期化時に、BowlingGame を作成します。(ViewModel 初期化時に、Model が初期化されるということです。)
- フレームの投球結果を取得する関数を作り、Model と接続しています。結果を文字列で返すことで、View 側で加工しやすくしています。
- フレーム時点でのスコアを返す関数を作り、ダミー実装を入れています。
上記の ViewModel の実装に合わせて、先ほど作った View に ViewModel への参照を追加しました。
長いですが、ざっくりいうと、以下のように手を入れました。
- FrameView, FrameBowlView, FrameScoreView, TotalScoreView は、BowlingGame の更新に伴い更新されるので、@ObservedObject として BowlingGameViewModel を保持します
- FrameView, FrameBowlView, FrameScoreView で、インデックスを保持し、自分が何フレーム目、何投目であるかをわかるようにしています
- View からは、ViewModel の持つ関数を呼んでいるだけで、ロジック等は入れていません
- フレームのインデックス番号は、内部では 0開始 ですが、人間的には 1開始 が普通なので、表示する時に、オフセットして表示してます
- ゲームのトータルスコアは、とりあえず、10フレームでのスコア計算結果を表示していますが、そのうち変更しないといけません
//
// 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))
}
}
上記のコードで、以下のような表示となります。
すこし Refactoring
View と ViewModel を繋げて気づきますが、View に繰り返しのコードが多すぎです。
特に、BowlingGameView の FrameView とか、InputView の Button が気になるので、修正します。
BowlingGameView と InputView を以下のように変更し、繰り返しのコードを減らしました。
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)
}
}
コードを変更したり追加する時に、少しづつ違う変更や少しづつ違うコードを書くことになったら、Refactoring するタイミングかもしれません。
UITest を追加
コードが見やすくなってスッキリしたところで、すこしテストを書きましょう。View も実装したので、UITest を書きます。
「アプリを起動して、1のボタンを押したら、1フレームの最初の投球のところに1が表示されるかをテスト」というテストを作ります。
テストに向けた準備
以下の記事でも説明していますが、UITest をする際には、Accessibility ID を設定しておくことがポイントです。
[SwiftUI] [UnitTest] テストの作り方 ( Button 編)
BowlingGameView でも テスト対象とする要素に Accessibility ID を設定しましょう。
今回は、InputView の Button と FrameBowlView の Text に設定しましょう。
以下のように、.accessibility modifier を使うことで設定できます。
Button(action: {}, label: { Text("Button") } )
.accessibility(identifier: "AccessibilityID")
Button と Text に、以下のように、.accessibility を設定します。
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))
}
}
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が表示されるかをテスト」というテストを作りたいので、テストコードとしては以下のようになります。
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 のボタンを押すために、Button1 という ID を持つボタンを取得します。(存在をテストしています)
- 1 フレームの 第1投の Text である FrameBowl0-0 という ID を持つテキストを取得します。(存在をテストしています)
- 投球前には、FrameBowl0-0 というテキストは、”-”を表示していることをテスト
- Button1 をタップ
- FrameBowl0-0 のテキストが、”1″ に変わっていることをテスト
テストを実行
テストを作ったので実行してみます。
現時点では、1のボタンを押されても何もしませんので、表示が更新されず、(5) のテストで失敗します。
テスト結果:XCTAssertEqual failed: (“-“) is not equal to (“1”)
テストを成功するように修正
Button の action が空ですので、実装していきます。
1を押されたら、BowlingGame に1を記録。2〜9も同様に、押された数字を記録するようにします。
ViewModel が、BowlingGame モデルを持っていますので、View -> ViewModel -> Model という形で、伝えていきます。
class BowlingGameViewModel : ObservableObject{
@Published var game: BowlingGame
// ... snip ...
func addBowlResult(num: Int) {
_ = game.addBowlResult(num)
}
}
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!
Sponsor Link