SwiftUIでcrossDissolveアニメーション付きの背景透過なフルモーダルをfullScreenCoverを用いて実現する

# SwiftUIでcrossDissolveアニメーション付きの背景透過なフルモーダルをfullScreenCoverを用いて実現する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/b1a32d2f-615c-412d-bcd1-d2a08f1da5e3.gif" width="320">

上のGifのように、階層構造を持ったsheetやpopoverの上からも開くことができる、背景透過なフルモーダルをSwiftUIの `fullScreenCover` ベースで実装したので、その方法について紹介します。

## コード全体

```swift
import SwiftUI
import UIKit

private struct ModalCover<Content: View>: View {
    @State private var backgroundOpacity: CGFloat = 0.0
    @State private var contentOpacity: CGFloat = 0.0
    @State private var contentScale: CGFloat = 1.2
    @State private var isAnimationCompleted: Bool = false

    var isPresented: Binding<Bool>
    var content: Content

    init(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content){
        self.isPresented = isPresented
        self.content = content()
     }

    var body: some View {
        ZStack {
            // モーダルコンテンツ
            content
                .opacity(contentOpacity)
                .scaleEffect(contentScale)

            // 背景のタップエリア
            Color.clear
                .contentShape(Rectangle())
                .ignoresSafeArea(.all)
                .onTapGesture {
                    if isAnimationCompleted {
                        isPresented.wrappedValue = false
                    }
                }
        }
        .background(CrossDissolveOverride()) // 閉じる時のアニメーションはCrossDissolveOverrideに任せる
        .presentationBackground(Color.black.opacity(backgroundOpacity))
        .onAppear {
            // 開いた時のアニメーション
            withAnimation(.easeInOut(duration: 0.2), completionCriteria: .logicallyComplete, {
                backgroundOpacity = 0.2
                contentOpacity = 1.0
                contentScale = 1.0
            }, completion: {
                isAnimationCompleted = true
            })
        }
    }

    struct CrossDissolveOverride: UIViewRepresentable {
        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            // SwiftUI.PresentationHostingController を取り出す
            DispatchQueue.main.async {
                var responder: UIResponder? = view.superview?.next
                while responder != nil {
                    guard let responder, let vc = responder as? UIViewController else {
                        responder = responder?.next
                        continue
                    }
                    // viewが紐づいているPresentationHostingControllerのmodalTransitionStyleをcrossDissolveにしておくことで、fullScreenCoverを閉じるときのアニメーションを標準のslideから変更できる
                    vc.modalTransitionStyle = .crossDissolve
                    break
                }
            }
            return view
        }

        func updateUIView(_ uiView: UIView, context: Context) {}
    }
}

// ViewModifierにしないとsheet/popoverから開いた際にtransaction(value:)の前にfullScreenCoverのslideInアニメーションが始まってしまう
private struct TransactionModifier: ViewModifier {
    var isPresented: Binding<Bool>

    func body(content: Content) -> some View {
        content
            .transaction(value: isPresented.wrappedValue) { transform in
                // 開かれるタイミングの時だけisPresentedを無効化する (トランジションのアニメーションではなくSwiftUI.Viewのアニメーションを利用する)
                transform.disablesAnimations = isPresented.wrappedValue
            }
    }
}

public extension View {
    func fullScreenModal(isPresented: Binding<Bool>, content: @escaping () -> some View) -> some View {
        fullScreenCover(isPresented: isPresented) {
            ModalCover(isPresented: isPresented, content: content)
        }
        .modifier(TransactionModifier(isPresented: isPresented))
    }
}
```

## 使い方
```swift
struct ContentView: View {
    @State private var isPresented: Bool = false
    var body: some View {
        Button(action: {
            isPresented = true
        }, label: {
            Text("カスタムモーダルを開く")
        })
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .fullScreenModal(isPresented: $isPresented) {
            Text("モーダル")
                .frame(width: 300, height: 300)
                .background(Color.white)
                .clipShape(RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal, 20)
        }
    }
}
```
`fullScreenCover` と同じような使用感で背景透過なフルモーダルが表示できます。


## コードの説明
通常の `fullScreenCover` だと、スクリーンサイズより小さいViewを `fullScreenCover` に指定しても白背景が表示されてしまいます。
また、スライドイン / スライドアウトアニメーションが表示されてしまいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84888/89338c79-68d4-4ed2-9919-42f488ae8b63.gif" width="320">

### Transitionアニメーションを消す

```swift
// ViewModifierにしないとsheet/popoverから開いた際にtransaction(value:)の前にfullScreenCoverのslideInアニメーションが始まってしまう
private struct TransactionModifier: ViewModifier {
    var isPresented: Binding<Bool>

    func body(content: Content) -> some View {
        content
            .transaction(value: isPresented.wrappedValue) { transform in
                // 開かれるタイミングの時だけisPresentedを無効化する (トランジションのアニメーションではなくSwiftUI.Viewのアニメーションを利用する)
                transform.disablesAnimations = isPresented.wrappedValue
            }
    }
}

public extension View {
    func fullScreenModal(isPresented: Binding<Bool>, content: @escaping () -> some View) -> some View {
        fullScreenCover(isPresented: isPresented) {
            ModalCover(isPresented: isPresented, content: content)
        }
        .modifier(TransactionModifier(isPresented: isPresented))
    }
}
```

Viewのextensionとして用意した `fullScreenModal` では `fullScreenCover` のコンテンツとして `ModalCover` を指定しています。また `modifier` として `TransactionModifier` を設定しています。

`TransactionModifier` は `transaction(value:_:)` が呼ばれた際にTransactionのdisablesAnimationsに `isPresented` の値を入れています。

TransactionのdisablesAnimationsを `false` にすることでデフォルトのトランジションアニメーションを消せるのですが、 `isPresented` が `true` の時 (表示時) のみ無効にしているのが肝です。

### 表示時のアニメーションをSwiftUIのView側で行う

先ほどデフォルトのトランジションをオフにしました。ModalCoverでは `onAppear` のタイミングで `withAnimation` を呼び出し、開いた時にフェードインアニメーションがされるように実装を行います。

```swift
private struct ModalCover<Content: View>: View {
    @State private var backgroundOpacity: CGFloat = 0.0
    @State private var contentOpacity: CGFloat = 0.0
    @State private var contentScale: CGFloat = 1.2
    @State private var isAnimationCompleted: Bool = false

    var isPresented: Binding<Bool>
    var content: Content

    init(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content){
        self.isPresented = isPresented
        self.content = content()
     }

    var body: some View {
        ZStack {
            // モーダルコンテンツ
            content
                .opacity(contentOpacity)
                .scaleEffect(contentScale)

            // 背景のタップエリア
            Color.clear
                .contentShape(Rectangle())
                .ignoresSafeArea(.all)
                .onTapGesture {
                    if isAnimationCompleted {
                        isPresented.wrappedValue = false
                    }
                }
        }
        .background(CrossDissolveOverride()) // 閉じる時のアニメーションはCrossDissolveOverrideに任せる
        .presentationBackground(Color.black.opacity(backgroundOpacity))
        .onAppear {
            // 開いた時のアニメーション
            withAnimation(.easeInOut(duration: 0.2), completionCriteria: .logicallyComplete, {
                backgroundOpacity = 0.2
                contentOpacity = 1.0
                contentScale = 1.0
            }, completion: {
                isAnimationCompleted = true
            })
        }
    }
}
```

### 非表示時のアニメーションにcrossDissolveを設定する

表示時のアニメーションはSwiftUIのViewで行いましたが、非表示時はSwiftUIのViewを表示している`PresentationHostingController` の `modalTransitionStyle` を `crossDissolve` に上書きすることで対応を行います。

「非表示時も `transform.disablesAnimations` を `true` に設定してデフォルトのトランジションアニメーションを消して、SwiftUIのAnimationで実現すればよいのでは...?」と思われるかもしれませんが、非表示時に `transform.disablesAnimations` を `true` にした場合、トランジションアニメーション無しですぐViewが閉じてしまうため、 `onDisappear` などのタイミングで `withAnimation` を発火させても、Viewのアニメーションがされないままモーダルを表示しているViewをホストしている `PresentationHostingController` がdismissされてしまうため、非表示時はトランジションのアニメーションを利用するようにしています。

```swift
private struct ModalCover<Content: View>: View {
    // ... 略

    var body: some View {
        ZStack {
            // ... 略
        }
        .background(CrossDissolveOverride()) // 閉じる時のアニメーションはCrossDissolveOverrideに任せる
        .presentationBackground(Color.black.opacity(backgroundOpacity))
        .onAppear {
            // ... 略
        }
    }

    struct CrossDissolveOverride: UIViewRepresentable {
        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            // SwiftUI.PresentationHostingController を取り出す
            DispatchQueue.main.async {
                var responder: UIResponder? = view.superview?.next
                while responder != nil {
                    guard let responder, let vc = responder as? UIViewController else {
                        responder = responder?.next
                        continue
                    }
                    // viewが紐づいているPresentationHostingControllerのmodalTransitionStyleをcrossDissolveにしておくことで、fullScreenCoverを閉じるときのアニメーションを標準のslideから変更できる
                    vc.modalTransitionStyle = .crossDissolve
                    break
                }
            }
            return view
        }

        func updateUIView(_ uiView: UIView, context: Context) {}
    }
}
```

`CrossDissolveOverride` というstructは `UIViewRepresentable` を用いてSwiftUIのViewからUIKitのUIViewへアクセスできるようにしています。

`fullScreenCover` によって表示されたSwiftUIのViewは、内部では`SwiftUI._UIHostingView<SwiftUI.AnyView>` というUIViewのクラスにラップされ`SwiftUI.PresentationHostingController` というUIViewControllerが表示しています。

`fullScreenCover` ではこの `SwiftUI.PresentationHostingController` をモーダル表示しているため、`UIViewReporesentable` を用いてUIKitの世界からresponder chainを辿って、　 `SwiftUI.PresentationHostingController` を見つけて、 `modalTransitionStyle` を `crossDissolve` にしてやることで、フルモーダルを閉じる時に `crossDissolve` なアニメーションが実現できるようにしています。