fullScreenCoverを下フリックで閉じれるようにする

# 概要

SwiftUIの`fullScreenCover`は`sheet`とは異なり、下方向のフリックで閉じることができません。
本記事では、カスタムDragGestureを作成し`fullScreenCover`でも下方向のフリックを可能にするサンプルコードを紹介しています。

## サンプルコード

```swift
import SwiftUI

enum Direction {
    case horizontal, vertical
}

struct ContentView: View {
    @State private var isCovered = false
    @State private var dragOffsetY: CGFloat = 0
    @State private var swipeDirection: Direction = .vertical

    var body: some View {
        VStack {
            Button(action: {
                isCovered = true
            }, label: {
                Text("Open Modal")
            })
        }
        .fullScreenCover(isPresented: $isCovered) {
            CarouselView(dragOffsetY: $dragOffsetY, swipeDirection: $swipeDirection)
                .background(TransparentBackgroundView())
        }
    }
}

struct CarouselView: View {
    @Binding var dragOffsetY: CGFloat
    @Binding var swipeDirection: Direction
    
    @Environment(\.dismiss) private var dismiss
    @GestureState private var dragOffsetX: CGFloat = 0
    private var cards = ["0", "1", "2"]
    @State private var currentIndex = 0

    init(dragOffsetY: Binding<CGFloat>, swipeDirection: Binding<Direction>) {
        self._dragOffsetY = dragOffsetY
        self._swipeDirection = swipeDirection
    }

    var body: some View {
        GeometryReader { bodyView in
            LazyHStack(spacing: 0) {
                ForEach(cards.indices, id: \.self) { index in
                    Text("\(index)")
                        .font(.title)
                        .foregroundStyle(.white)
                        .frame(width: bodyView.size.width, height: bodyView.size.height)
                        .background(.black)
                }
            }
            .offset(x: self.dragOffsetX - CGFloat(currentIndex) * bodyView.size.width)
            .offset(y: dragOffsetY)
            .gesture(
                DragGesture()
                    .updating(self.$dragOffsetX, body: { (value, state, _) in
                        // 変位の大きい方向を操作方向とする
                        if abs(value.translation.width) > abs(value.translation.height) {
                            swipeDirection = .horizontal
                        } else {
                            swipeDirection = .vertical
                        }
                        
                        switch swipeDirection {
                        case .horizontal:
                            // 先頭・末尾では背景が見えないように変位を0にする
                            if currentIndex == 0, value.translation.width > 0 {
                                state = 0
                            } else if currentIndex == (cards.count - 1), value.translation.width < 0 {
                                state = 0
                            } else {
                                state = value.translation.width
                            }
                        case .vertical:
                            break
                        }
                    })
                    .onChanged { value in
                        switch swipeDirection {
                        case .horizontal:
                            break
                        case .vertical:
                            // 上方向のスワイプは無視する
                            if value.translation.height < 0 {
                                dragOffsetY = 0
                            } else {
                                dragOffsetY = value.translation.height
                            }
                        }
                    }
                    .onEnded({ value in
                        switch swipeDirection {
                        case .horizontal:
                            var newIndex = currentIndex
                            // ドラッグ幅からページングを判定
                            if abs(value.translation.width) > bodyView.size.width * 0.15 {
                                newIndex = value.translation.width > 0 ? currentIndex - 1 : currentIndex + 1
                            }
                            
                            // 最小ページ、最大ページを超えないようチェック
                            if newIndex < 0 {
                                newIndex = 0
                            } else if newIndex > (cards.count - 1) {
                                newIndex = cards.count - 1
                            }
                            
                            currentIndex = newIndex
                            
                        case .vertical:
                            let threshold = UIScreen.main.bounds.height / 2
                            if value.velocity.height > threshold || value.translation.height > threshold {
                                // モーダルを閉じる
                                withAnimation(.linear(duration: 0.05)) {
                                    dismiss()
                                }
                                // 閉じた後にオフセットを0に戻す
                                Task { @MainActor in
                                    try await Task.sleep(nanoseconds: 500_000_000)
                                    dragOffsetY = .zero
                                }
                            } else {
                                // 閉じなかった場合、オフセットを0に戻す
                                withAnimation(.linear(duration: 0.05)) {
                                    dragOffsetY = .zero
                                }
                            }
                        }
                    })
                
            )
        }
        .animation(.interpolatingSpring(mass: 0.6, stiffness: 150, damping: 80, initialVelocity: 0.1), value: currentIndex)
    }
}

// fullScreenCoverを閉じる際sheetのように背景透過させるための記述
private struct TransparentBackgroundView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        Task { @MainActor in
            view.superview?.superview?.backgroundColor = .clear
        }
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {}
}

#Preview {
    ContentView()
}
```