【SwiftUI入門講座Part6】サイコロアプリを作ってみよう!~ボタン・タイマー機能を利用~

作成中です。

Part6では、以下のようなサイコロアプリを開発します。ボタンを押したらサイコロの目がランダムで表示されます。

プロジェクト作成

まずはプロジェクトを作成しましょう。

STEP.1
プロジェクトを作成

Xcodeを立ち上げて、Create a new Xcode projectをクリック。

この画面が開かない場合は、command + shift + 1を押してください。

STEP.2
テンプレートを選択

①iOSを選択
②Appを選択
③Nextをクリック

STEP.3
プロジェクトの設定

①Product Nameに、Dice
②Interfaceは、SwiftUI
③Languageは、Swift
④全てチェックなし
⑤Nextをクリック

STEP.4
保存するフォルダを選択

Part2で作ったPracticeフォルダを選択してCreateをクリック

これでプロジェクトが作成できました。

レイアウト実装

まずは、見た目の部分レイアウトを実装していきましょう。

STEP.1
Resumeで確認

まずは、Resumeを押して画面を確認してください。そうすると、Hello, world!の画面が表示されると思います。この講座の最中は、こまめにResumeを押して画面を確認してみてください。

STEP.2
ImageとButtonを配置

Text("Hello, world!")の部分を以下のように書き換えましょう。

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "die.face.1")
            Button(action: {
                print("ボタンが押されたよ")
            }) {
                Text("サイコロを振る")
            }
        }
    }
}

Resumeを押すと、このようなレイアウトになっているはずです。

コード解説

縦に並べたいので、VStackを使います。

VStackの中には、2つのオブジェクトがあります。

1. 画像

Image(systemName: "die.face.1")

Image(systemName: "画像の名前")で、Xcodeにあらかじめ登録されている画像を表示できます。

登録されている画像の一覧は、SF Symbolsというアプリをダウンロードすると一覧がみれます。SwiftUI開発では必須のアプリなのでインストしておきましょう。

2. ボタン

Button(action: {
    print("ボタンが押されたよ")
}) {
    Text("サイコロを振る")
}

前回配置したボタンとちょっと違う書き方ですが、これでボタンが配置できます。

action: {の中に、ボタンを押されたときの処理を書いて、その下の}) {の中に、ボタンのレイアウトを書きます。

STEP.3
画像の装飾

画像が小さすぎるので大きくしましょう。

Image(systemName: "die.face.1")
    .resizable()
    .scaledToFit()
    .frame(width: UIScreen.main.bounds.width/2)
    .padding()

コード解説

Image()に対して、.~~~というふうに書くと、そのオブジェクトに装飾ができます。これをモディファイアと言います。なので、今回は、Imageに4つのモディファイアをつけてるみたいなイメージです。

今回つけたモディファイアを一つ一つ解説します。

.resizable()

画像の大きさを変更する時は必ずこのモディファイアをつけないといけません。

.scaledToFit()

画像の縦横の比率を固定するというモディファイアです。これを指定しないと、画像が伸びちゃったりぺちゃんこになったりします。

.frame(width: UIScreen.main.bounds.width/2)

.frameは、オブジェクトの大きさを変更できるモディファイアです。画像の比率を固定するという指定をしているので、ここでは幅だけ指定しています。

UIScreen.main.bounds.widthで、画面の幅が取得できます。それを/2しているので、画面の幅の半分の大きさに指定するという意味になります。

.padding()

.padding()は、内側の余白です。HTML/CSSに触れたことがある方はわかりやすいと思います。.padding(30)というふうに数値で余白の大きさを変えることができます。デフォルトは16です。

STEP.4
ボタンの装飾

次はボタンを装飾しましょう。

Text("サイコロを振る")
    .padding()
    .background(.orange)
    .foregroundColor(.black)
    .cornerRadius(10)

ビルドするとこのようなレイアウトになっているはずです。

コード解説

こちらもモディファイアを一つ一つ解説していきます。

.padding()

内側の余白です。

.background(.orange)

背景色を変更できます。実は、.background(Color.orange).Colorを省略している形になっています。

.foregroundColor(.black)

文字色を変更できます。ボタンの中の文字は自動的に青色になるので、ここで黒文字に変更しています。

.cornerRadius(10)

角に丸みをつけることができます。ボタンには丸みをつけると大体いい感じになります。

STEP.5
間隔を変更する

現時点では、真ん中に寄りすぎているので、もう少し画像とボタンのスペースを広げてあげます。

そういう時は、Spacer()を追記してバランスを取ります。

Imageの上と下、ボタンの下にSpacer()を追記しましょう。

VStack {
    Spacer()
    Image(systemName: "die.face.1")
        .resizable()
        .scaledToFit()
        .frame(width: UIScreen.main.bounds.width/2)
        .padding()
    Spacer()
    Button(action: {
        print("ボタンが押されたよ")
    }) {
        Text("サイコロを振る")
            .padding()
            .background(Color.orange)
            .foregroundColor(.black)
            .cornerRadius(10)
    }
    Spacer()
}

コード解説

Spacer()は、限界までスペースを作ることができます。複数あるとスペースの幅は均等になります。

SwiftUIはこのような感じでSpacer()を使ってレイアウトを整えていきます。

これでレイアウトがいい感じになりました。

機能実装

では、いよいよサイコロを振る機能を作っていきましょう。

STEP.1
サイコロを変更してみる

例えば、サイコロの出目を3に変更するには、"die.face.1""die.face.3"というふうに変更します。

試しに、変更して実行してみましょう。

Image(systemName: "die.face.3")

このように出目が3に変わっているはずです。

そのため、"die.face.1"の数値を変えることで、出目を変更できるということがわかりました。

STEP.2
変数を追加

"die.face.1"の数値を変数で持つようにしましょう。

@State var randomNumber = 1

STEP.3
数値を変数にする

では、先ほど作った変数を、画像の数値に対応させます。

Imageを以下のように記載してください。

ちなみにバックスラッシュ(\)は、option + ¥で打てます。

Image(systemName: "die.face.\(randomNumber)")

コード解説

\()で囲うと文字列の中に変数を入れることができます。

つまり、randomNumberが1なら、"die.face.1"randomNumberが3なら、"die.face.3"というふうになります。現状randomNumberが1なので、出目は1になるはずです。

STEP.4
ボタンを押したら出目を変更する

では、ボタンを押したら出目を4に変更してみましょう。

Button(action: {
    print("ボタンが押されたよ")
    randomNumber = 4
}) {

実行してボタンを押してみると、出目が1から4になるはずです。

STEP.5
ランダムにしてみる

今は、ボタンを押すと強制的に4になりますが、これをランダムに変更してみましょう。

randomNumber = Int.random(in: 1...6)

これで、ボタンを押すたびにサイコロの出目が変わるはずです。

これで最低限のサイコロアプリが完成しました!

アニメーション実装

現時点では、ボタンを押したら一瞬で出目が変わるので、前回と同じ出目だった場合、ボタンを押せたのかどうかがわかりにくいです。そのため、ボタンを押したら、サイコロを振ってる感を出しましょう。

では、どのようにするかというと、適当な出目を連続で0.5秒間、高速で表示させます。

どのように処理で実装するのかというと、ボタンを押したら、出目を0.1秒ごとにランダムに変えていきます。そして、0.5秒経ったら止めます。これで良い感じのアニメーションっぽくなります。

では実装してきましょう。

STEP.1
変数を追加

0.1秒ごとに処理をするには、Timerを使います。

まず、timerという変数を追加します。

@State var timer: Timer?

STEP.2
0.1秒ごとに出目を変更

randomNumberにランダムな値を入れているところを以下のように変更しましょう。

Button(action: {
    print("ボタンが押されたよ")
    timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
        randomNumber = Int.random(in: 1...6)
    }
}) {

これで、実行してボタンを押してみてください。高速で0.1秒ごとに出目が変わり続けると思います。

コード解説

このブロックの中に処理を書くと、0.1秒ごとに実行されます。

timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
}

ちなみに、withTimeInterval0.2とかにすると、切り替わりが少し遅くなります。

STEP.3
0.5秒後に止める

では、次に、ボタンを押してから0.5秒後に出目を止めるようにしましょう。

Button(action: {
    print("ボタンが押されたよ")
    timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
        randomNumber = Int.random(in: 1...6)
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        timer?.invalidate()
        timer = nil
    }
}) {

コード解説

遅らせて処理を行いたい時には、DispatchQueueを使います。

以下のコードの中に書くと、0.5秒後に実行されます。

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
}

そして、この2行でtimerをストップさせています。

timer?.invalidate()
timer = nil

これで、アニメーションが完成しました!良い感じに動いています!

バグ修正

よくできていると思いきや、バグを発見しました。

2回連続タップすると、一生止まらなくなってしまいます。

簡単に理由を説明すると、1回目押したタイマーが止まる前に、2回目のタイマーが起動するため、1回目のタイマーを止めるタイミングを失ってしまったからです。

では、どのように対応するのかというと、2回連続タップさせないようにしましょう。(押すたびにタイマー止めるのもあり)

STEP.1
ボタンを無効にする

2回連続タップさせないようにするには、1回目押されてから0.5秒間は、ボタンを無効にするという処理を入れないといけません。

ボタンを無効にするには、以下のように書きます。とりあえず、無効にして実行して確認しましょう。

Button(action: {
    //省略
}) {
    Text("サイコロを振る")
        //省略
}
.disabled(true)

実行してボタンを押してみるとボタンが反応しないはずです。

コード解説

つまり、.disabled(true)は、ボタンを無効にさせるかどうかのモディファイアです。

trueにすると無効になり、

falseにすると有効になります。

ということで、今回の対応は、ボタンを押してから0.5秒間は、trueで、それ以外はfalseという変数を用意すれば良いということになります。

STEP.2
変数を用意

ボタンを押してから0.5秒間は、trueで、それ以外はfalseという変数を用意します。

以下のコードを追記しましょう。

@State var isRolling = false

STEP.3
Boolを切り替える

ボタンを押してから、0.5秒間をtrueにするので、以下のようにコードを追記しましょう。

Button(action: {
    print("ボタンが押されたよ")
    isRolling = true
    timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
        randomNumber = Int.random(in: 1...6)
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        timer?.invalidate()
        timer = nil
        isRolling = false
    }
}) {

コード解説

ボタンを押した瞬間に、isRollingtrueにして、0.5秒後にisRollingfalseにしているだけです。

STEP.4
isRollingを反映

isRollingを、.disabledに反映させましょう。

Button(action: {
    //省略
}) {
    Text("サイコロを振る")
        //省略
}
.disabled(isRolling)

実行して確認してみましょう。

おそらくボタンを2回連続タップしても永遠と止まらなくなることはないかと思います。

これで、サイコロアプリは完成です!

import SwiftUI

struct ContentView: View {
    @State var randomNumber = 1
    @State var timer: Timer?
    @State var isRolling = false
    
    var body: some View {
        VStack {
            Spacer()
            Image(systemName: "die.face.\(randomNumber)")
                .resizable()
                .scaledToFit()
                .frame(width: UIScreen.main.bounds.width/2)
                .padding()
            Spacer()
            Button(action: {
                print("ボタンが押されたよ")
                isRolling = true
                timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
                    randomNumber = Int.random(in: 1...6)
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    timer?.invalidate()
                    timer = nil
                    isRolling = false
                }
            }) {
                Text("サイコロを振る")
                    .padding()
                    .background(.orange)
                    .foregroundColor(.black)
                    .cornerRadius(10)
            }
            .disabled(isRolling)
            Spacer()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

リファクタリング(余裕があったら)

リファクタリングというのは、アプリの動作や見た目を変えずに、コードを見やすく整理したり、保守性を高めたりすることです。

では、今回のコードをリファクタリングしていきましょう。

処理を関数化する

基本的に、var body: some View {の中には処理を書かないようにしましょう。

STEP.1
関数を作る

var body: some View {のブロックの下に、以下のコードを追記しましょう。

func playDice() {
}

STEP.2
処理を移動

Button(action: {の中に書いてある処理を全て関数の中に移動させましょう。

func playDice() {
    print("ボタンが押されたよ")
    isRolling = true
    timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
        randomNumber = Int.random(in: 1...6)
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        timer?.invalidate()
        timer = nil
        isRolling = false
    }
}

STEP.3
関数を呼ぶ

Button(action: {の中で先ほど作った関数を呼びましょう。

Button(action: {
    playDice()
}) {

これで、処理を分けることができました。

STEP.5
動作確認

リファクタリングしたら、必ず実行して挙動が変わっていないかを確認しましょう。

これで、処理を関数化することができました。

privateをつける

そのstruct内でしか使わない、変数や定数、関数には、privateをつけましょう。

privateをつけると、他のstructから参照できなくなります。だから何?と思われるかもしれません。正直、理由は「念のため」です。例えば、家に誰も来ないってわかってても、家の鍵は閉めますよね。それと同じです。一応鍵をかけておくって感じです。

以下のように、変数と関数にprivateをつけましょう。

変数

@State private var randomNumber = 1
@State private var timer: Timer?
@State private var isRolling = false

関数

private func playDice() {

これで、リファクタリング完了です!綺麗なコードでかけました!

import SwiftUI

struct ContentView: View {
    @State private var randomNumber = 1
    @State private var timer: Timer?
    @State private var isRolling = false
    
    var body: some View {
        VStack {
            Spacer()
            Image(systemName: "die.face.\(randomNumber)")
                .resizable()
                .scaledToFit()
                .frame(width: UIScreen.main.bounds.width/2)
                .padding()
            Spacer()
            Button(action: {
                playDice()
            }) {
                Text("サイコロを振る")
                    .padding()
                    .background(.orange)
                    .foregroundColor(.black)
                    .cornerRadius(10)
            }
            .disabled(isRolling)
            Spacer()
        }
    }
    
    private func playDice() {
        print("ボタンが押されたよ")
        isRolling = true
        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            randomNumber = Int.random(in: 1...6)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            timer?.invalidate()
            timer = nil
            isRolling = false
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

GitHubにも載せています。

参考 DiceGitHub