musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

SwiftUIでダイアログ

今回はSwiftUIでダイアログを表示する話。
様々なダイアログを表示してモデルのプロパティを変更できる機構を考えます。

ダイアログの動作



はじめに

musicLineではMIDI楽譜を編集するために様々なダイアログ(ポップアップでプロパティを設定するView)を表示します。

例えば、

  • 曲名やテンポの変更
  • 楽器の変更
  • 編集トラックの変更
  • フレーズの追加 等

ダイアログの使用例

AndroidではDialogFragmentの継承することでダイアログをカスタマイズしてました。
ダイアログを表示する際は、.showメソッドによりFragmentManagerに加えて表示していました。

iOSでも同じような使い方ができるようにSwiftUIで実装しました。


ちなみに、そこまでダイアログのデザインや挙動を制御する必要がないのであれば、モディファイア.confirmationDialog.sheetも使用できます。

capibara1969.com

blog.code-candy.com



ダイアログの仕様

今回の想定するダイアログの仕様です。

  1. ヘッダーに X ボタンとタイトルを表示
  2. X ボタンかダイアログ外をタップして閉じる
  3. ダイアログの中身を変更できるようにする

ダイアログの仕様

またダイアログは、どこからでも呼び出せるようにします。

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を表示しています。


ダイアログをカスタマイズ

次に、DialogDialogModelableを派生させて、独自のダイアログへカスタマイズします。
例えば、ダイアログの中身をステッパーにする場合

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にしてライフサイクルを意識した方がいいかもしれないですね。
それとこの実装だと同じダイアログを複数開くことができないことも気になります。。
(そもそもそんな状況を作ったらいけないか?)

とりあえずこのダイアログの機構をベースに改善していきます。