今回はSwiftUIでダイアログを表示する話。
様々なダイアログを表示してモデルのプロパティを変更できる機構を考えます。
はじめに
モデルのプロパティを変更する際は、様々なダイアログ(ポップアップView)を表示します。
例えば、MIDI楽譜を編集するアプリmusicLineでは
- 曲名やテンポの変更
- 楽器の変更
- 編集トラックの変更
- フレーズの追加
等があります。
しかし、ダイアログのデザインは決まっており、様々なダイアログを作るたびに同じようなコードを書かなくてはいけません。
この記事では、このようなボイラープレートを無くして様々なダイアログを作成する方法を検討します。
なお、簡易なダイアログで良い場合やダイアログのデザインや挙動を使い回す必要がないのであれば、適当なViewを作成してモディファイア.confirmationDialog
や.sheet
で表示すれば良いでしょう。
ダイアログの仕様
今回の想定するダイアログの仕様です。
- ヘッダーに X ボタンとタイトルを表示
- X ボタンかダイアログ外をタップして閉じる
- ダイアログの中身を変更できるようにする
またダイアログは、こんな感じでソースのどこからでも呼び出せるようにしたいと思います。
myDialog.open(model: instance)
実装
今回は各々のボタンをタップすることで、3種類のダイアログが表示されるViewを作ってみました。
共通ダイアログ
まず、仕様を満たす抽象的なダイアログとそのモデルを定義します。
struct Dialog<TView: View, TDialogModel: DialogModelable>: View {
let dialogModel: TDialogModel
@ViewBuilder let content: () -> TView
...
var body: some View {
if dialogModel.isOpen {
ZStack{
// 黒背景(タップで閉じる)
blackScreen
// ダイアログの中身
content()
...
}
.zIndex(1)
}
}
private var blackScreen: some View {
Color.black
.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
dialogModel.close()
}
.contentShape(Rectangle())
}
...
}
protocol DialogModelable{
var title: String { get }
var isOpen: Bool { get }
func close()
}
dialogModel.isOpen
により、表示と非表示を切り替えます。
背景blackScreen
をタップした時に、dialogModel.close()
でダイアログを閉じます。
ダイアログの中身は派生ダイアログでカスタマイズできるように、content
クロージャーにしています。
また、Dialog
のヘッダーの実装について、
struct Dialog<TView: View, TDialogModel: DialogModelable>: View {
...
private let headerHeight = 35.0
var body: some View {
...
// ダイアログの中身
content()
.padding(.top, headerHeight)
.overlay(titleBar)
...
}
// ヘッダー
private var titleBar: some View {
VStack{
ZStack(alignment: .top){
Color.gray.frame(height: headerHeight)
HStack{
Image(systemName: "multiply")
.onTapGesture {
dialogModel.close()
}
.padding(10)
Spacer()
}
Text(dialogModel.title)
.lineLimit(1)
.font(.headline)
.padding(6)
}
Spacer()
}
}
}
content().padding
で上に空間を作って、overlay
でヘッダーを付けてます。
ヘッダーでは X ボタンとタイトルdialogModel.title
を表示しています。
ダイアログをカスタマイズ
次に、Dialog
とDialogModelable
を派生させて、独自のダイアログへカスタマイズします。
例えば、ダイアログの中身をステッパーにする場合
struct NumberStepperDialog: View {
@Bindable var numberStepper: NumberStepperDialogModel
var body: some View {
Dialog(dialogModel: numberStepper){
Stepper("\(numberStepper.number.value)",value: $numberStepper.number.value)
.frame(width: 200)
}
}
}
@Observable
class NumberStepperDialogModel: DialogModelable{
let title = "Stepper"
var number = Number()
private(set) var isOpen = false
func open(number: Number){
self.number = number
isOpen = true
}
func close(){
isOpen = false
}
}
なお、Viewへの通知はObservationを使っているので、iOS17以降で動作します。
それ以前でしたら、Combine等に置き換えてください。
ダイアログモデルをシングルトンで管理
ダイアログをどこからでも呼び出せるようにシングルトンでダイアログモデルを管理します。
class DialogManager {
static let shared = DialogManager()
let numberStepper = NumberStepperDialogModel()
...
}
@Observable
class Number{
var value = 0
}
let number = Number() // 編集対象モデル
// ダイアログを呼び出す
DialogManager.shared.numberStepper.open(number: number)
ダイアログを貼り付ける
呼び出された時にダイアログを表示できるように、Viewにダイアログを貼り付けます。
struct ContentView: View {
...
let dm = DialogManager.shared
var body: some View {
ZStack{
NumberStepperDialog(numberStepper: dm.numberStepper)
...
}
}
}
ソースコード等の詳細は元記事へ
おわりに
今回はSwiftUIでダイアログを表示する方法を検討しました。
ダイアログはデザインや挙動が似てくるので共通処理をまとめて、ボイラープレートを減らしました。
実際にはanimationをつけたり、キーボード表示の挙動など考慮する点があると思います。
あとシングルトンを使ってますが、他の方法でDIした方がいいかもしれないです。
まだまだ検討の余地はありそうですね。