2Dスロットの作り方

今度Flutterでも同じように作る予定なのでSwift以外の言語でも応用が効くように作りました。
忘れないようにノウハウを記録します。サンプルコードは一番下にあります。

#用語
ググる時間がなかったので自分で考えました。より適切な名前を思いつく方は教えていただければ幸いです。

- Symbol: シンボル
- Square: シンボルが入る箱
- List: 見えない部分も含むリール全体
- ReelView: リールのうち見える部分だけ
- Window: 複数のリールを横に並べたもの

![img1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/69e722bb-13df-8a8b-4297-bc1c453c91dd.png)


# 手順

## 1. List

まずはListを作成します。SymbolとSquareを縦に並べただけです。滑らかにループさせるため、上下に2つずつ付け足しておくことがポイントです。見える部分が縦長のスロットの場合はもっと付け足してください。

![img2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/fee623ca-c209-e6b4-43e9-a291d1e08d3a.png)

## 2. ReelView

スクロール用のUI部品を使います。手順3で登場するoffset変数に合わせて自動的にスクロールするようにUIと紐付けしておきましょう。
難しそうに見えますが、やっていることはただListをスクロールさせているだけです。回転方向を変えたい時はoffsetを下から取りましょう。

![img3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/33da31b4-29e9-cf6b-4d36-4791cf3b0a28.png)

## 3. 定数と状態変数

手順2と同時進行で作ります。上に出てきた図の通りです。

- 定数(Const)
  - baseOffset: スクロール量の初期値
  - size: シンボルやリールの横幅, 単位サイズ
- 状態変数(State)
  - offset: 現在のスクロール量
  - index: 現在真ん中に表示しているシンボルの番号
  - stopIndex: 止まっても良いシンボルの番号

## 4. 関数

スロットを回すための関数を作っていきます。フローチャートもどきを書いてみました。
あとで全体のコードを書きます。

- ```start()```
  - 回転を開始するためにloop()を呼び出す
  - 例えばここでタイムアウトを指定して何秒か経ったら自動的に回転を停止させることも可能
- ```loop()```
  - 順番にduration(), spinOne(), canStop()を呼び出した後、必要ならもう1度自分自身を呼び出す再帰関数
- ```duration()```
  - 回転の速さを決める
  - 止まる直前は少しゆっくりにするのがオススメ
- ```spinOne()```
  - 回転を1つ進める
  - これに合わせて見た目のUIも更新される
- ```canStop()```
  - 回転を続けるか、止まるかを判断する
- ```stop()```
  - 回転を停止するために、止まっても良い位置を更新する
- ```didStop()```
  - 回転が止まった時に呼ばれる
  - 何もしないなら不要
  - この関数を作る代わりにstop()を非同期にしても良い

![img4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/b1715baf-a87d-53c6-326b-a2db4f9675e8.png)

## 5. Window

サンプルコード上には入れていませんが、リールを3つほど横に並べた後、影に見えるように黒色のグラデーションをつけることで、円盤が回っている感が出ます。

![img5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/5397a35f-9c5a-a9a2-edd2-7df0b61e1662.png)

# サンプルコード

最後にSwiftUIでの実装例を載せて終わりです。ありがとうございました🙌

- ContentView.swift

```ContentView.swift
import SwiftUI

// リールの状態: リールの数だけ好きな場所に持って置く
let state = State()

struct ContentView: View {
    let c = Controller()
    var body: some View {
        VStack {
            Spacer()
            // サンプルではリールは1個だけ
            ReelView()
            Spacer()
            // 回転スタートボタン
            Button { c.start() } label: { Text("Start") }
            Spacer()
            // 1か4 で回転ストップボタン
            Button { c.stop(at: [1, 4]) } label: { Text("Stop") }
            Spacer()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.black)
    }
}

enum Const {
    static let baseOffset: CGFloat = 75
    static let size: CGFloat = 50
    // 画像がないので数字を Symbol として使う
    static let symbols = [0, 1, 2, 3, 4]
}

class State: ObservableObject {
    // UIと紐付けたいので @Published
    @Published var offset: CGFloat = Const.baseOffset
    var index: Int = 0
    var stopIndex: [Int] = []
}

struct Controller {

    // はやく回転するときのスピード
    let reelDurationFast = 0.1
    // ゆっくり回転するときのスピード
    let reelDurationSlow = 0.5
    
    func start() {
        state.stopIndex = []
        loop()
    }

    func loop() {
        spinOne() {
            if canStop() { didStop() }
            else { loop() } // 再帰呼び出し
        }
    }

    func duration(toIndex: Int) -> Double {
        return state.stopIndex.contains(toIndex) ? reelDurationSlow : reelDurationFast
    }

    func spinOne(completion: @escaping () -> Void) {
        // 現在の index を取得
        let oldIndex = state.index
        var newIndex = oldIndex + 1
        // index の限界を超えていた場合は内部保持のデータのみ 0 にする
        let maxIndex = Const.symbols.count - 1
        if newIndex > maxIndex {
            newIndex = 0
        }
        let oldOffset = CGFloat(oldIndex) * Const.size
        let newOffset = CGFloat(oldIndex + 1) * Const.size
        // 時間をかけて値を変化させる
        let d = duration(toIndex: newIndex)
        DispatchQueue.main.async {
            state.index = newIndex
            state.offset = oldOffset
            withAnimation(.linear(duration: d)) {
                state.offset = newOffset
            }
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + d) {
            completion()
        }
    }

    func canStop() -> Bool {
        return state.stopIndex.contains(state.index)
    }

    func stop(at index: [Int]) {
        state.stopIndex = index
    }

    func didStop() {
        print("回転が止まりました。 at: \(state.index)")
    }
}

struct ReelView: View {
    @StateObject var s = state

    func square(_ symbol: Int) -> some View {
        return Text(String(symbol))
            .font(.system(size: 30))
            .foregroundColor(.gray)
            .frame(width: Const.size, height: Const.size)
            .background(Color.white)
    }

    var body: some View {
        ScrollView {
            // 説明上は List と呼ぶ部分
            VStack(spacing: 0) {
                // ループ用に末尾2つ
                square(Array(Const.symbols.suffix(2)).first!)
                square(Array(Const.symbols.suffix(2)).last!)
                // リールの本体部分
                ForEach(Const.symbols, id: \.self) { symbol in
                    square(symbol)
                }
                // ループ用に先頭2つ
                square(Array(Const.symbols.prefix(2)).first!)
                square(Array(Const.symbols.prefix(2)).last!)
            }
            .offset(.init(width: 0, height: -s.offset)) // 上からoffsetを計算したのでマイナス補正
        }
        .frame(width: Const.size, height: Const.size * 2)
        .background(Color.white)
    }
}

```

※ start() と stop() だけ外から呼び出せるように protocol にした方が安全ですね