SwiftUIの仕組みを活用してアプリ共通のダイアログを表示する
表示するダイアログ
今回はこのようなポップアップ形式のダイアログを用意しました
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からその関数を呼び出すことができます。実際にdismissやopenURLはEnvironmentに定義されているstructであり、子Viewからのアクションとして利用することができます。
実装してみる
Actionを定義
まずはEnvironmentで利用するstructを定義します。
ShowDialogAction
はプロパティにactionを持ち、関数名なしで子Viewから呼び出せるようcallAsFunctionを定義しています。関数の処理はactionを呼び出すだけです。
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を呼び出せば完成です。
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 }))
}
}
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がこのように書けます。
かなりスッキリしましたね!
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