今回はSwiftUIでダイアログを表示する話。
様々なダイアログを表示してモデルのプロパティを変更できる機構を考えます。
はじめに
musicLineではMIDI楽譜を編集するために様々なダイアログ(ポップアップでプロパティを設定するView)を表示します。
例えば、
- 曲名やテンポの変更
- 楽器の変更
- 編集トラックの変更
- フレーズの追加 等
AndroidではDialogFragment
の継承することでダイアログをカスタマイズしてました。
ダイアログを表示する際は、.show
メソッドによりFragmentManager
に加えて表示していました。
iOSでも同じような使い方ができるようにSwiftUIで実装しました。
ちなみに、そこまでダイアログのデザインや挙動を制御する必要がないのであれば、モディファイア.confirmationDialog
や.sheet
も使用できます。
ダイアログの仕様
今回の想定するダイアログの仕様です。
- ヘッダーに X ボタンとタイトルを表示
- X ボタンかダイアログ外をタップして閉じる
- ダイアログの中身を変更できるようにする
またダイアログは、どこからでも呼び出せるようにします。
myDialog.open(model: instance)
実装
今回は各々のボタンで異なるダイアログが表示される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) ... } } }
全コードを表示
import SwiftUI struct ContentView: View { // MARK: Property let number = Number() // 編集対象モデル let dm = DialogManager.shared var body: some View { ZStack{ VStack{ menu.padding() Text("\(number.value.description)") } NumberStepperDialog(numberStepper: dm.numberStepper) NumberPickerDialog(numberPicker: dm.numberPicker) NumberTextFieldDialog(numberTextField: dm.numberTextField) } } @ViewBuilder var menu: some View{ Button("Dialog 1"){ dm.numberStepper.open(number: number) } Button("Dialog 2"){ dm.numberPicker.open(number: number) } Button("Dialog 3"){ dm.numberTextField.open(number: number) } } }
import SwiftUI 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) } } } struct NumberPickerDialog: View { @Bindable var numberPicker: NumberPickerDialogModel var body: some View { Dialog(dialogModel: numberPicker){ Picker("\(numberPicker.number.value)", selection: $numberPicker.number.value) { ForEach(0...10, id: \.self) { number in Text("\(number)") } } .pickerStyle(.wheel) .frame(width: 200) } } } struct NumberTextFieldDialog: View { @Bindable var numberTextField: NumberTextFieldDialogModel var body: some View { Dialog(dialogModel: numberTextField){ TextField("\(numberTextField.number.value)", value: $numberTextField.number.value, format: .number) .keyboardType(.numberPad) .multilineTextAlignment(.center) .frame(width: 50, height: 50) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray)) .padding(.top, 15) } } }
import SwiftUI @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 } } @Observable class NumberPickerDialogModel: DialogModelable{ let title = "Picker" var number = Number() private(set) var isOpen = false func open(number: Number){ self.number = number isOpen = true } func close(){ isOpen = false } } @Observable class NumberTextFieldDialogModel: DialogModelable{ let title = "Text Field" var number = Number() private(set) var isOpen = false func open(number: Number){ self.number = number isOpen = true } func close(){ isOpen = false } }
import Foundation @Observable class Number{ var value = 0 }
class DialogManager { // MARK: Property static let shared = DialogManager() let numberStepper = NumberStepperDialogModel() let numberPicker = NumberPickerDialogModel() let numberTextField = NumberTextFieldDialogModel() // MARK: Initializer private init() {} }
import SwiftUI struct Dialog<TView: View, TDialogModel: DialogModelable>: View { // MARK: Property // Stored let dialogModel: TDialogModel @ViewBuilder let content: () -> TView private let headerHeight = 35.0 private let padding = 10.0 private let margin = 10.0 private let cornerRadius = 10.0 var body: some View { if dialogModel.isOpen { ZStack{ // 黒背景(タップで閉じる) blackScreen // ダイアログの中身 content() .padding(padding) .padding(.top, headerHeight) .frame(minWidth: 300, minHeight: 100) .overlay(titleBar) .background(Color.white) .cornerRadius(cornerRadius) .padding(margin) } .zIndex(1) } } private var blackScreen: some View{ Color.black .opacity(0.3) .ignoresSafeArea() .onTapGesture { dialogModel.close() } .contentShape(Rectangle()) } 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() } } }
protocol DialogModelable{ var title: String { get } var isOpen: Bool { get } func close() }
おわりに
今回はAndroidのようにダイアログを管理して、SwiftUIでダイアログを表示する方法を模索してみました。
ダイアログの中身に色々なViewを適用できるようにするため、Dialog.titleBar
がトリッキーな実装になってしまいました。もう少しスマートで適切な実装があるかも?
実際にはanimationをつけたり、キーボード表示の挙動など考慮する点があると思います。
あとシングルトンを使ってますが、@Environment
にしてライフサイクルを意識した方がいいかもしれないですね。
それとこの実装だと同じダイアログを複数開くことができないことも気になります。。
(そもそもそんな状況を作ったらいけないか?)
とりあえずこのダイアログの機構をベースに改善していきます。