【SwiftUI】Gestureについて

# はじめに

SwiftUIのGestureについてドキュメントを読みました。

https://developer.apple.com/documentation/swiftui/gestures


# 内容

### Gesture
ジェスチャーモディファイアを使用して、アプリケーションにインタラクティブ性を追加する

https://developer.apple.com/documentation/swiftui/adding-interactivity-with-gestures

#### Viewにジェスチャーモディファイアを追加

[`gesture(_:including:)`](https://developer.apple.com/documentation/swiftui/view/gesture(_:including:))を使う

```swift
struct ShapeTapView: View {
    var body: some View {
        let tap = TapGesture()
            .onEnded { _ in
                print("View tapped!")
            }
        
        return Circle()
            .fill(Color.blue)
            .frame(width: 100, height: 100, alignment: .center)
            .gesture(tap)
    }
}
```

[`onTapGesture(count:coordinateSpace:perform:)`](https://developer.apple.com/documentation/swiftui/view/ontapgesture(count:coordinatespace:perform:))を使うパターンも

```swift
    let newGesture = TapGesture().onEnded {
        print("Tap on VStack.")
    }

    var body: some View {
        VStack(spacing:25) {
            Image(systemName: "heart.fill")
                .resizable()
                .frame(width: 75, height: 75)
                .padding()
                .foregroundColor(.red)
                .onTapGesture {
                    print("Tap on image.")
                }
            Rectangle()
                .fill(Color.blue)
        }
        .gesture(newGesture)
        .frame(width: 200, height: 200)
        .border(Color.purple)
    }
```

#### ジェスチャーコールバックに対応
受け取る方法は3つ

- [`updating(_:body:)`](https://developer.apple.com/documentation/swiftui/gesture/updating(_:body:))
- [`onChanged(_:)`](https://developer.apple.com/documentation/swiftui/gesture/onchanged(_:))
- [`onEnded(_:)`](https://developer.apple.com/documentation/swiftui/gesture/onended(_:))

#### UIの状態を更新
Gestureが変更されたときにViewを更新するには、Viewに[`GestureState`](https://developer.apple.com/documentation/swiftui/gesturestate)プロパティを追加し、[`updating(_:body:)`](https://developer.apple.com/documentation/swiftui/gesture/updating(_:body:))でそれを更新する
Gestureを終了またはキャンセルしたとき、状態プロパティは自動的にその状態を初期値にリセットする

```swift
struct CounterView: View {
    @GestureState var isDetectingLongPress = false
    
    var body: some View {
        let press = LongPressGesture(minimumDuration: 1)
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
            }
        
        return Circle()
            .fill(isDetectingLongPress ? Color.yellow : Color.green)
            .frame(width: 100, height: 100, alignment: .center)
            .gesture(press)
    }
}
```

👇 押すと一瞬黄色くなり、すぐに緑に戻る
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/f375e581-bcc5-76e4-1c8a-82fcbf1e41a5.png">

カウントアップなどの実装は、[`onChanged(_:)`](https://developer.apple.com/documentation/swiftui/gesture/onchanged(_:))を使う

```swift
struct CounterView: View {
    @GestureState var isDetectingLongPress = false
    @State var totalNumberOfTaps = 0
    
    var body: some View {
        let press = LongPressGesture(minimumDuration: 1)
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
            }.onChanged { _ in
                self.totalNumberOfTaps += 1
            }
        
        return VStack {
            Text("\(totalNumberOfTaps)")
                .font(.largeTitle)
            
            Circle()
                .fill(isDetectingLongPress ? Color.yellow : Color.green)
                .frame(width: 100, height: 100, alignment: .center)
                .gesture(press)
        }
    }
}
```

👇 押すと一瞬黄色くなり、すぐに緑に戻る + カウントが押すたびに上がっていく
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/f65d8fc1-1526-e5d8-15cf-03d363e65bf3.png"><img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/8394ae97-17cb-a9be-36a7-1b974c79af31.png">

Gestureの最終値を取得するには、[`onEnded(_:)`](https://developer.apple.com/documentation/swiftui/gesture/onended(_:))を使用する
Gestureが成功したときだけonEndedを呼び出すため、最小持続時間秒が経過する前にViewに触れるのを止めるか、最大距離ポイントより多く指を動かすと、onEndedは呼び出されない

```swift
struct CounterView: View {
    @GestureState var isDetectingLongPress = false
    @State var totalNumberOfTaps = 0
    @State var doneCounting = false
    
    var body: some View {
        let press = LongPressGesture(minimumDuration: 1)
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
            }.onChanged { _ in
                self.totalNumberOfTaps += 1
            }
            .onEnded { _ in
                self.doneCounting = true
            }
        
        return VStack {
            Text("\(totalNumberOfTaps)")
                .font(.largeTitle)
            
            Circle()
                .fill(doneCounting ? Color.red : isDetectingLongPress ? Color.yellow : Color.green)
                .frame(width: 100, height: 100, alignment: .center)
                .gesture(doneCounting ? nil : press)
        }
    }
}
```

👇 押すと一瞬黄色くなり、すぐに緑に戻る + カウントが押すたびに上がっていく + 長押しすると赤に変わり押せなくなる
<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/8d275bf2-b15c-f0fe-a8c1-0f993e518fe0.png"><img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/8394ae97-17cb-a9be-36a7-1b974c79af31.png"><img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/dc367065-227a-5948-37fd-95f262448b71.png">

### コンビネーション

ジェスチャーを組み合わせて、複雑なインタラクションを作る

https://developer.apple.com/documentation/swiftui/composing-swiftui-gestures

Gestureを認識する順番を定義するためにジェスチャーコンポジションを使用する

タイプは3種類ある

- Simultaneous（同時）

- Sequenced（連続）

- Exclusive（排他）

#### 連続で認識させるGestureの状態をモデル化する

複雑な状態の追跡を容易にするために、Viewを構成するのに必要なすべての状態をキャプチャするenumを作る

- インタラクションなし（inactive）
- 長押し中（pressing）
- ドラッグ中（drag）

```swift: DraggableView
    enum DragState {
        case inactive
        case pressing
        case dragging(translation: CGSize)
        
        var translation: CGSize {
            switch self {
            case .inactive, .pressing:
                return .zero
            case .dragging(let translation):
                return translation
            }
        }
        
        var isActive: Bool {
            switch self {
            case .inactive:
                return false
            case .pressing, .dragging:
                return true
            }
        }
        
        var isDragging: Bool {
            switch self {
            case .inactive, .pressing:
                return false
            case .dragging:
                return true
            }
        }
    }
    
    @GestureState var dragState = DragState.inactive
    @State var viewState = CGSize.zero
```

#### Gestureの作成

2 つのジェスチャをシーケンス化すると、コールバックは両方のジェスチャの状態を取得する

```swift: DraggableView
    var body: some View {
        let minimumLongPressDuration = 0.5
        let longPressDrag = LongPressGesture(minimumDuration: minimumLongPressDuration)
            .sequenced(before: DragGesture())
            .updating($dragState) { value, state, transaction in
                switch value {
                // Long press begins.
                case .first(true):
                    state = .pressing
                // Long press confirmed, dragging may begin.
                case .second(true, let drag):
                    state = .dragging(translation: drag?.translation ?? .zero)
                // Dragging ended or the long press cancelled.
                default:
                    state = .inactive
                }
            }
            .onEnded { value in
                guard case .second(true, let drag?) = value else { return }
                self.viewState.width += drag.translation.width
                self.viewState.height += drag.translation.height
            }
        return Circle()
            .fill(Color.blue)
            .overlay(dragState.isDragging ? Circle().stroke(Color.white, lineWidth: 2) : nil)
            .frame(width: 100, height: 100, alignment: .center)
            .offset(
                x: viewState.width + dragState.translation.width,
                y: viewState.height + dragState.translation.height
            )
            .shadow(radius: dragState.isActive ? 8 : 0)
            .animation(.linear(duration: minimumLongPressDuration), value: dragState.isDragging)
            .gesture(longPressDrag)
    }
}
```

Viewを押し始めると、ドラッグの状態が押すに変わり、シェイプの下に影がアニメーション表示され、
長押しを終了し、ドラッグの状態が変わると、Viewを動かし始めることができることを示すボーダーがシェイプの周りに表示される

|Start|Moving|End|
|:--:|:--:|:--:|
|<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/aaae7a25-c671-4768-3675-0b28eab48dd3.png">|<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/3e1653d7-3ed5-dfe7-417b-5ddf37bf10e4.png">|<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/353d2ddc-73b8-db7d-7548-74f7bc90e132.png">|

# おわりに

ざっと内容は理解できましたが、まだまだ奥が深そうなので、ユースケースを考えてさらに調べていこうと思います！

# 参考

https://developer.apple.com/documentation/swiftui/gestures

https://developer.apple.com/documentation/swiftui/composing-swiftui-gestures

https://qiita.com/kalupas226/items/153fdaf783379e7d865a