🪧

SwiftUIの仕組みを活用してアプリ共通のダイアログを表示する

2023/12/18に公開

表示するダイアログ

今回はこのようなポップアップ形式のダイアログを用意しました

CustomDialog.swift
struct CustomDialog: View {
    struct Content {
        let title: String
        let message: String
        var onConfirm: (() -> Void)?
        var onCancel: (() -> Void)?
    }
    var content: Content
    var onDismiss: (() -> Void)
    
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                Color.black.opacity(0.7)
                    .ignoresSafeArea()
                
                VStack(spacing: 24) {
                    Text(content.title).font(.title)
                    Text(content.message)
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                    HStack {
                        Button {
                            content.onCancel?()
                            onDismiss()
                        } label: {
                            Text("Cancel")
                                .frame(maxWidth: .infinity, maxHeight: .infinity)
                        }
                        Button {
                            content.onConfirm?()
                            onDismiss()
                        } label: {
                            Text("OK")
                                .frame(maxWidth: .infinity, maxHeight: .infinity)
                        }
                    }
                    .frame(height: 56)
                }
                .padding([.top, .horizontal])
                .frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.7)
                .background(.white)
                .clipShape(RoundedRectangle(cornerRadius: 16.0))
            }
        }
    }
}

普通に表示した場合の問題点

このダイアログは必要なViewから呼び出すことを想定していますが、下の画像のView呼び出し元のViewが小さい場合、想定しないレイアウトとなってしまいます。

また、全画面のViewであってもNavigationStackの影響で下にずれたり、TabViewの影響で上にずれたりしてしまいます。

(左)NavigationStack, (右)TabView

どうすれば良いのか

この問題を回避するためにはNavigationStackやTabViewにの影響を受けにくいRootの近くでダイアログを表示する必要があります。

子や孫Viewから親Viewに対してデータを渡す方法としてSwiftUIにはPreferenceが存在します。しかしPreferenceはナビゲーションバーのタイトルなど、子Viewでセットした値を親viewでキャッチするような仕組みのため、今回のダイアログ(表示文言やボタンタップ時のアクション)には向いていません。

そこで今回利用するのはEnvironmentです。Environmentは親Viewから子Viewへデータを渡す方法ですが、structを渡すことで子Viewからその関数を呼び出すことができます。実際にdismissopenURLはEnvironmentに定義されているstructであり、子Viewからのアクションとして利用することができます。

実装してみる

Actionを定義

まずはEnvironmentで利用するstructを定義します。
ShowDialogActionはプロパティにactionを持ち、関数名なしで子Viewから呼び出せるようcallAsFunctionを定義しています。関数の処理はactionを呼び出すだけです。

ShowDialogAction.swift
struct ShowDialogAction {
    var action: ((CustomDialog.Content) -> Void)
    
    func callAsFunction(_ content: CustomDialog.Content) {
        action(content)
    }
}

Environmentを定義

Environmentを利用するためのお作法的な部分ですが、ShowDialogKeyの定義とEnvironmentValuesの拡張をします。

struct ShowDialogKey: EnvironmentKey {
    static var defaultValue: ShowDialogAction = .init(action: { _ in })
}

extension EnvironmentValues {
    var showDialog: ShowDialogAction {
        get { self[ShowDialogKey.self] }
        set { self[ShowDialogKey.self] = newValue }
    }
}

これでEnvironmentとして利用できるようになりました。

最後に、RootViewでShowDialogActionをEnvironmentとして設定し、ChildViewからactionを呼び出せば完成です。

RootView.swift
struct RootView: View {
    @State private var dialogContent: CustomDialog.Content?
    
    var body: some View {
        TabView {
            ChildView()
                .tabItem { Label("A", systemImage: "house") }
            Text("View B")
                .tabItem { Label("B", systemImage: "gearshape") }
        }
        .overlay {
            if let dialogContent {
                CustomDialog(content: dialogContent, onDismiss: { self.dialogContent = nil })
            }
        }
        .environment(\.showDialog, ShowDialogAction(action: { dialogContent = $0 }))
    }
}
ChildView.swift
struct ChildView: View {
    @Environment(\.showDialog) var showDialog
    
    var body: some View {
        VStack {
            Text("ChildView")
            Button("ダイアログ表示") {
                showDialog(.init(title: "タイトル", message: "メッセージ"))
            }
            .buttonStyle(.borderedProminent)
        }
        .padding(50)
        .border(.blue)
    }
}

RootViewをスッキリさせる

今回はRootViewに3つのコードを書きました。

  • dialogContent(プロパティ)
  • CustomDialog(Viewの定義)
  • environment(Environmentの設定)

しかし、今後要素が増えた場合見通しが悪くなってしまうためダイアログ表示部分をどうにかして分離したいところです。

このような場合にはViewModifierを作って処理を分離するのがおすすめです。

struct DialogModifier: ViewModifier {
    @State private var dialogContent: CustomDialog.Content?
    
    func body(content: Content) -> some View {
        content
            .overlay {
                if let dialogContent {
                    CustomDialog(content: dialogContent, onDismiss: { self.dialogContent = nil })
                }
            }
            .environment(\.showDialog, ShowDialogAction(action: { dialogContent = $0 }))
    }
}

extension View {
    func dialogDisplayable() -> some View {
        self.modifier(DialogModifier())
    }
}

ViewModifierとViewの拡張をしておくとRootViewがこのように書けます。
かなりスッキリしましたね!

RootView.swift
struct RootView: View {
    var body: some View {
        TabView {
            ChildView()
                .tabItem { Label("A", systemImage: "house") }
            Text("View B")
                .tabItem { Label("B", systemImage: "gearshape") }
        }
        .dialogDisplayable()
    }
}

まとめ

今回はSwiftUIでの開発を通して感じた共通ダイアログ表示の問題点の整理と、ダイアログ表示のための共通機構を作る方法について書いてみました。
なんとなく利用していたdismissやopenURLも定義を見てみるとSwiftやSwiftUIの仕組みを上手く利用しており、自分でもそのような仕組みを作れることに可能性を感じますね。
この記事がアプリ共通View表示の参考になれば幸いです。

Discussion