SwiftUIでボタンの同時押しを制御するテクニック

## はじめに
SwiftUIで複数のボタンを並べた時に同時押しを制御して１つのタップイベントのみ受け取る方法について調べたことをまとめました。

## 環境
Xcode 16.1 (16B40)
Apple Swift version 6.0.2 (swiftlang-6.0.2.1.2 clang-1600.0.26.4)

## １つのタップイベントのみ受け取る実装
設計の雛形をChat GPTとのやりとりを繰り返し作りました。
ボタンのタップ状態を管理するStateを追加しています。
具体的にはExclusiveButtonStateでタップしたボタンのidを保持し、idがリセットされるまで他のボタンのアクションを実行できないように制御する仕組みになります。
ExclusiveButtonと組み合わせて、この状態管理を実現しています。

```swift
final class ExclusiveButtonState: ObservableObject {
    @Published var activeButtonID: String?

    func isButtonActive(_ buttonID: String) -> Bool {
        return activeButtonID == buttonID
    }

    var canPress: Bool {
        return activeButtonID == nil
    }

    func setActiveButton(_ buttonID: String) {
        activeButtonID = buttonID
    }

    func resetActiveButton() {
        activeButtonID = nil
    }
}

struct ExclusiveButton<Content: View>: View {
    let id: String
    @ObservedObject var buttonState: ExclusiveButtonState
    let action: () -> Void
    let content: () -> Content // ボタンの外観を定義するクロージャ
    
    var body: some View {
        Button(action: {
            if buttonState.canPress {
                buttonState.setActiveButton(id)
                action()
            }
        }) {
            content() // 外部から提供されるView
        }
        .disabled(!buttonState.canPress && !buttonState.isButtonActive(id))
    }
}
```

ボタンを３つ並べたサンプルを作りました。
１つボタンを押すと1秒後にButtonのidのリセットを行うことで１つのアクションのみ受け付けるようにしました。
```swift
struct ExclusiveButtonView: View {
    @StateObject private var buttonState = ExclusiveButtonState()
    
    var body: some View {
        VStack(spacing: 20) {
            ExclusiveButton(id: "button1", buttonState: buttonState) {
                simulateLongTask()
            } content: {
                Text("Button 1")
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            
            ExclusiveButton(id: "button2", buttonState: buttonState) {
                simulateLongTask()
            } content: {
                Text("Button 2")
                    .padding()
                    .background(Color.green)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            
            ExclusiveButton(id: "button3", buttonState: buttonState) {
                simulateLongTask()
            } content: {
                Text("Button 3")
                    .padding()
                    .background(Color.red)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
        }
    }
    
    func simulateLongTask() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            buttonState.resetActiveButton()
        }
    }
}

#Preview {
    ExclusiveButtonView()
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63985/051a172f-3141-8143-02d2-4ffa5eedf5eb.png" width="300">


## タップ時に１つのボタンのみハイライトさせたい
最初のコードでは、複数のボタンを同時に押すとすべてのボタンがハイライトされてしまいます。改善策として、ボタンのtouchDownタイミングでアクションを制御する必要があります。

下記の記事を参考にButtonStyleを定義し、touchDownを取得可能にします。

https://note.com/soeno_hiroki/n/ndfd8ee393814

onTouchDownでアクションの制御をし、onTouchUpでbuttonのidをリセットし、タップを有効に戻します。
ハイライト時のUIはButtonStyleで実装します。
```swift
struct TouchTrackingButtonStyle: ButtonStyle {
    let onTouchDown: () -> Void
    let onTouchUp: () -> Void

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed) { isPressed in
                if isPressed {
                    onTouchDown()
                } else {
                    onTouchUp()
                }
            }
            .opacity(configuration.isPressed ? 0.6 : 1.0) // ハイライト効果として透明度を変更
            .contentShape(Rectangle())
    }
}

struct ExclusiveButton<Content: View>: View {
    let id: String
    @ObservedObject var buttonState: ExclusiveButtonState
    let action: () -> Void
    let content: () -> Content // ボタンの外観を定義するクロージャ
    
    var body: some View {
        Button {
            // 空のアクション（TouchTrackingButtonStyleで管理するため）
        } label: {
            content() // 外部から提供されるView
        }
        .buttonStyle(TouchTrackingButtonStyle(
            onTouchDown: {
                if buttonState.canPress {
                    buttonState.setActiveButton(id)
                    action() // Touch Downでのアクション
                }
            },
            onTouchUp: {
                buttonState.resetActiveButton()
            }
        ))
        .disabled(!buttonState.canPress && !buttonState.isButtonActive(id))
    }
}
```

ButtonStyleを拡張することでアニメーションも追加できます。
```swift
struct AnimatedTouchTrackingButtonStyle: ButtonStyle {
    let onTouchDown: () -> Void
    let onTouchUp: () -> Void
    
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed) { isPressed in
                if isPressed {
                    onTouchDown()
                } else {
                    onTouchUp()
                }
            }
            .opacity(configuration.isPressed ? 0.6 : 1.0) // ハイライト効果として透明度を変更
            .scaleEffect(configuration.isPressed ? 1.1 : 1.0) // 押下時に拡大
            .animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
            .contentShape(Rectangle())
    }
}
```
## まとめ
SwiftUIにおいて複数ボタンの中で1つのタップイベントのみを受け付ける機能を実現できました。また、タップ時に1つのボタンだけがハイライトされるよう、ButtonStyleをカスタマイズすることでUIも改善しました。