SwiftUIとUIKitが共存している環境でカスタムダイアログを作ろうとして踏んだ罠

現状(2022/12現在)のSwiftUIでは`alert`にカスタムViewを設定することができません。
そのため、UIKit時代のようにカスタムダイアログを作ろうとすると力技[^1]になりがちです。
[^1]:https://qiita.com/yyokii/items/ec984833c4d040001fca

しかし、UIKitとSwiftUIを共存させようとしているプロジェクトでカスタムダイアログを作ろうとした際に思わぬ引っ掛かり方をしたので共有します。

# 何が起きたか
## やったこと
StackOverflow[^3]を参考にカスタムViewをセットできるViewModifierを作りました。
[^3]:https://stackoverflow.com/questions/66547657/swiftui-full-screen-view-over-navigationbar-and-tabbar

※だいぶ端折ってますがそれでも長いので畳んでます
<details>
<summary>CustomDialog.swift</summary>
<div>

```swift:CustomDialog.swift
public extension View {
    func dialog<Content: View>(isPresented: Binding<Bool>,
                               isCloseEnabled: Bool = false,
                               @ViewBuilder _ content: @escaping () -> Content,
                               onOk okAction: @escaping () -> Void = {}) -> some View {
        modifier(DialogView(isPresented: isPresented,
                            isCloseEnabled: isCloseEnabled,
                            dialogContent: content,
                            actions: [
                                .init(actionType: .ok, 
                                      isPrimary: true, isDestructive: false, handler: okAction)
                            ]))
    }
}

struct DialogView<DialogContent: View>: ViewModifier {
    @Binding var isPresented: Bool
    var isCloseEnabled: Bool
    var dialogContent: () -> DialogContent
    var actions: [Action]

    public func body(content: Content) -> some View {
        content
            .animation(nil, value: isPresented)
            .overlay(isPresented ? backgroundView : nil)
            .overlay(isPresented ? contentView : nil)
            .animation(.easeInOut(duration: 0.1), value: isPresented)
    }
}

extension DialogView {
    var backgroundView: some View {
        Color.black
            .opacity(0.4)
            .ignoresSafeArea()
            .onTapGesture {
                if isCloseEnabled {
                    isPresented = false
                }
            }
    }

    var contentView: some View {
        VStack(spacing: 0) {
            dialogContent()
                .padding()
            Divider()
                .foregroundColor(.gray)
            HStack(spacing: 1) {
                ForEach(actions) { action in
                    let weight: Font.Weight = action.isPrimary ? .bold : .regular
                    let color: Color = action.isDestructive ? .red : .blue
                    Button {
                        withAnimation(.easeInOut(duration: 0.1)) {
                            isPresented = false
                        }
                        action.handler()
                    } label: {
                        Text(action.actionType.title)
                            .font(.body)
                            .fontWeight(weight)
                            .foregroundColor(color)
                            .frame(height: 44)
                            .frame(maxWidth: .infinity)
                    }
                    .background(Color.white)
                }
            }.background(Color.gray)
        }
        .frame(width: 270)
        .background(Color.white)
        .cornerRadius(8)
    }
}
```
</div>
</details>

<details>
<summary>SampleView.swift</summary>
<div>

```swift:SampleView.swift
struct SampleView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var isPresented = false
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    Spacer()
                    Button {
                        isPresented = true
                    } label: {
                        Text("Hello, World!")
                            .frame(maxWidth: .infinity)
                    }
                    ForEach(1...50, id: \.self) {
                        Text(String($0))
                    }
                    Spacer()
                }.padding()
            }.background(Color.white)
                .navigationViewStyle(StackNavigationViewStyle())
                .navigationBarItems(
                    leading: Button {
                        dismiss()
                    } label: {
                        Image(systemName: "xmark")
                    },
                    trailing: Button {
                        isPresented = true
                    } label: {
                        Image(systemName: "ellipsis.circle")
                    }
                )
        }.dialog(isPresented: $isPresented) {
            VStack {
                Image(systemName: "exclamationmark.bubble")
                    .resizable()
                    .frame(width: 40, height: 40)
                    .foregroundColor(.brown)
                Text("Hello, World!")
                    .font(.title).bold()
            }
        }
    }
}
```
</div>
</details>

通常であればこれでそれっぽいカスタムダイアログが作れるはずです。
実際、サンプル用に作ったシンプルなアプリでは正常に表示されました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135304/45772a35-ed00-4812-3087-15768bf1fb42.png" width="320">

## 実際に起きたこと
実際のプロダクトで起きたことを↑のサンプルアプリで再現しました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135304/bd5a9182-a3ff-1835-b834-e3776ad2fafe.png" width="320">

※実際のアプリではNavigtionViewは使わずViewのHostingControllerをNavigationControllerにセットしています。

```swift:RootViewController.swift
let host = UIHostingController(rootView: SampleView())
let nav = UINavigationController(rootViewController: host)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
```

# 背景
iOS15でUINavigationBarに仕様変更があり、ざっくり言うとスクロール時に背景が透過されるようになりました。
そこで対策として `UINavigationBar.appearance()` の `standardAppearance` と `scrollEdgeAppearance` に同じ設定を施していました[^2]。
[^2]:https://blog.personal-factory.com/2021/12/29/ios15-transparent-navigationbar-and-tabbar-by-default/

```swift
let standardAppearance = UINavigationBarAppearance()
standardAppearance.configureWithOpaqueBackground()
standardAppearance.backgroundColor = .white
nav.standardAppearance = standardAppearance
nav.scrollEdgeAppearance = standardAppearance // ← ココ
```

その結果、SwiftUIで定義されたViewの上から白いViewが乗っかるようになっていました。

厳密にはこの書き方は正しくなくて、View Hierarchyで見たところSwiftUIで定義されたViewの上に`scrollEdgeAppearance`から描画されたViewが乗っかっているようです。
そのため、`scrollEdgeAppearance`をデフォルトのまま使っている場合は透過されているので想定どおり見えているだけでした。

|`scrollEdgeAppearance` セット前  |`scrollEdgeAppearance` セット後 |
|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135304/b7d12414-c03c-801f-0377-bcd157c5985c.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135304/10da4339-de70-3e77-0913-f91dbdead9e7.png)|

また、↑のスクショからView側でセットした`navigationBarItems`も`scrollEdgeAppearance`の上に乗るため、ダイアログが表示中でも押せてしまうという罠にも気づきました。

# 結局
`scrollEdgeAppearance`の設定を戻すのは影響範囲が大きすぎて非現実的です。
そのため、SwiftUI側から直接カスタムダイアログを呼ぶのは断念せざるを得ませんでした。

そこで、SwiftUIの方がカスタムダイアログが書きやすいのは事実なので苦肉の策としてDialogView専用のHostingControllerを作ってUIKitから呼び出すようにしました。
これは仕上がったら別の記事にするかもしれません。

# 最後に
今後、カスタムViewのダイアログを作ろうと思っている方は同じ轍を踏まないように気をつけていただければ幸いです。

また、良い解決方法をご存知の方がいたらコメント欄やTwitterで教えて下さい:pray: