SwiftUIでダイアログ（UIコードの使い回し）

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

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231103/20231103101206.gif" title="ダイアログの動作"> 

開発アプリPR
<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
    <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231111/20231111170552.png" alt="３分作曲-musicLine-" title="3分作曲-musicLine- Androidアプリ">
</a>


<br>
<br>

# はじめに

モデルのプロパティを変更する際は、様々なダイアログ（ポップアップView）を表示します。  

例えば、MIDI楽譜を編集するアプリmusicLineでは

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

等があります。

<img src="
https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231103/20231103103210.gif" title="ダイアログ使用例"> 

<br>

しかし、ダイアログのデザインは決まっており、様々なダイアログを作るたびに同じようなコードを書かなくてはいけません。
この記事では、このようなボイラープレートを無くして様々なダイアログを作成する方法を検討します。

<br>

なお、簡易なダイアログで良い場合やダイアログのデザインや挙動を使い回す必要がないのであれば、適当なViewを作成してモディファイア`.confirmationDialog`や`.sheet`で表示すれば良いでしょう。

https://capibara1969.com/3774

https://blog.code-candy.com/swiftui_sheet_half



<br>
<br>

# ダイアログの仕様

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

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

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231103/20231103110100.png" title="ダイアログの仕様"> 

またダイアログは、こんな感じでソースのどこからでも呼び出せるようにしたいと思います。

```
myDialog.open(model: instance)
```

<br>
<br>

# 実装

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231103/20231103101206.gif" title="ダイアログの動作"> 

今回は各々のボタンをタップすることで、3種類のダイアログが表示されるViewを作ってみました。

<br>

## 共通ダイアログ

まず、仕様を満たす抽象的なダイアログとそのモデルを定義します。  

```swift
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())
    }
    ...

}
```

```swift
protocol DialogModelable{
    var title: String { get }
    var isOpen: Bool { get }
    func close()
}
```

`dialogModel.isOpen`により、表示と非表示を切り替えます。  
背景`blackScreen`をタップした時に、`dialogModel.close()`でダイアログを閉じます。
ダイアログの中身は派生ダイアログでカスタマイズできるように、`content`クロージャーにしています。

<br>

また、`Dialog`のヘッダーの実装について、

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

<br>

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

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


```swift
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)
        }
    }
}
```

```swift
@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等に置き換えてください。


<br>

## ダイアログモデルをシングルトンで管理

ダイアログをどこからでも呼び出せるようにシングルトンでダイアログモデルを管理します。

```swift
class DialogManager {
    
    static let shared = DialogManager()
    let numberStepper = NumberStepperDialogModel()
    ...
}
```

```swift
@Observable 
class Number{
    var value = 0
}
let number = Number()    // 編集対象モデル

// ダイアログを呼び出す
DialogManager.shared.numberStepper.open(number: number)
```


<br>

## ダイアログを貼り付ける

呼び出された時にダイアログを表示できるように、Viewにダイアログを貼り付けます。

```swift
struct ContentView: View {

    ...
    let dm = DialogManager.shared

    var body: some View {
        ZStack{

            NumberStepperDialog(numberStepper: dm.numberStepper)
            ...
        }
    }

}
```


ソースコード等の詳細は元記事へ

https://musicline-developer.hatenablog.com/entry/2023/11/03/144952?_gl=1*1h4ey0u*_gcl_au*NzcwMjc2MzYzLjE2OTUwODE3MjE.#:~:text=open(model%3A%20instance)-,%E5%AE%9F%E8%A3%85,-%E3%83%80%E3%82%A4%E3%82%A2%E3%83%AD%E3%82%B0%E3%81%AE%E5%8B%95%E4%BD%9C


<br>
<br>

# おわりに

今回はSwiftUIでダイアログを表示する方法を検討しました。
ダイアログはデザインや挙動が似てくるので共通処理をまとめて、ボイラープレートを減らしました。
実際にはanimationをつけたり、キーボード表示の挙動など考慮する点があると思います。  
あとシングルトンを使ってますが、他の方法でDIした方がいいかもしれないです。
まだまだ検討の余地はありそうですね。

https://musicline-developer.hatenablog.com/entry/2023/11/03/144952?_gl=1*1h4ey0u*_gcl_au*NzcwMjc2MzYzLjE2OTUwODE3MjE

開発アプリPR
<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
    <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231111/20231111170552.png" alt="３分作曲-musicLine-" title="3分作曲-musicLine- Androidアプリ">
</a>

<br>