SwiftUIでハーフモーダルを表示するライブラリ ResizableSheet

最近、ハーフモーダル(セミモーダル)を使っているアプリが増えてきましたね。
僕も個人開発しているアプリでハーフモーダルを使いたい時がよくあるのですが、SwiftUIで手軽に実装する方法がなかったので、 [ResizableSheet](https://github.com/mtj0928/ResizableSheet) というライブラリを作りました。
この記事では、ハーフモーダルの実装とその煩雑さ、そして開発したライブラリであるResizableSheetの紹介をします。

(最初の方はハーフモーダルの話をするので、ResizableSheetについて早く知りたい人は [後半](https://qiita.com/matsuji/items/1c4ff827d73b330c2c55#resizablesheet)から読んでください。笑)

# ハーフモーダルとは
iOS標準のマップアプリなどで使われている下から出てくるシートです。
<img width="320pt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/a3f7cbc6-6d3c-779c-3a3f-0a6dd9d42546.png"> <img width="320pt" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/ef979ea3-bc99-2f63-5e4c-86ebdc4cdc0a.png">

特徴としては、常に画面全体を覆っているわけではなく、 ユーザーがドラッグすることで隠したり画面全体にしたりすることもできます。
iOS標準のアプリ以外にも多くのアプリがこのハーフモーダルを採用していて、今や主流なUIの一つになっています。

ハーフモーダルが使われるようになった背景に、端末の巨大化があると思ってます。
端末が大きくなったことで画面の上にあるUIコンポーネントに親指が届かない、というのはUXとしてよくありません。
そのような問題を解決する方法の一つがこのハーフモーダルです。
画面の上の方に表示しているUIをドラッグで下の方に移動させ、親指だけで操作できます。

# 実装の煩雑さ
ハーフモーダルは多くのサードパーティーのアプリで使われているだけでなく、Apple自身も使っているので、UIKitやSwiftUIで用意されていそうです。
`UISheetPresentationController`がそれに相当します。
しかし、これはiOS15以上でしか使えない上、高さをカスタマイズするといったこともできず、画面いっぱいのサイズかちょうど半分のサイズにしかできません。
なので、少しでも凝ったUIにしようとする自前実装する必要が出てきます。

iOS標準以外にもUIKit向けのサードパーティーのライブラリもあります。
有名どころとして[FloatingPanel](https://github.com/SCENEE/FloatingPanel)があります。
これは実際かなりパワフルなライブラリでして、カスタマイズ性が非常に高いです。
僕も昔使っていましたし、非常にお世話になったライブラリです。
ただ使っていて大変だったのが、ハーフモーダルの高さを決める実装です。
自分で具体的な値を設定しなければならず、例えばハーフモーダルの中身に応じて高さを変更する場合、そのサイズを計算するコードを実装する必要があり、少し手間でした。
また、個人開発しているアプリの場合、新規で作成するUIのほとんどをSwiftUIで作成しているため、SwiftUIから手軽に使えないというのも少し手間でした。

SwiftUIでハーフモーダルが表示できそうなライブラリとしては、[PartialSheet](https://github.com/AndreaMiotto/PartialSheet)があります。
これも良さそうなのですがこれはハーフモーダルをドラッグして全画面にする、といったことはできないようになっています。
また、スクロールビューが中にある場合、そのスクロールに合わせてハーフモーダルの開閉ができない仕様なのも少し残念でした。

# 求めているライブラリ
ここまでのおさらいをしますと、僕が求めているハーフモーダルを表示するライブラリは次のような仕様を満たす必要があります。

- SwiftUIで気軽に呼び出せる
- ハーフモーダルの高さはカスタマイズできるようにしたいが、利用する側で高さの計算をしたくない
- スクロールビューのスクロールで高さが変わる

これらを満たすライブラリとして、ResizableSheetを開発しました。

# ResizableSheet
[ResizableSheet](https://github.com/mtj0928/ResizableSheet)を開発する上で気をつけたことは、カスタマイズ性を残しつつ、できるだけ少ないコードで開発者がハーフモーダルを表示できるようにすることです。

先にResizableSheetでできることを書いておきます。

- 3つの高さの状態を管理 (`hidden`, `medium`, `large`)
- 各状態に応じてビューの更新が可能
- `medium`の高さを自動で計算
- ドラッグで非表示にすることも可能。できないようにすることも可能。
- スクロールビューのスクロールとの同期
- ハーフモーダルを表示した状態で呼び出し元のViewの操作可 (デフォルトは不可)
- 複数のハーフモーダルを重ねることも可能

## 簡単な例
まずは簡単なハーフモーダルをResizableSheetで実装してみましょう。
ResizableSheetの実装は次の3つのステップです。

1. SwiftPMで[ResizableSheet](https://github.com/mtj0928/ResizableSheet)をインストールする
2. アプリの一番根っことなるViewで `UIWindowScene`をもとに `ResizableSheetCenter`を用意し、Environmentとして埋め込む
3. ハーフモーダルを表示したい画面でハーフモーダルを定義し、表示する


1.の説明は省略します。2.で `UIWindowScene` が必要な理由は、他のモーダルViewが表示されている状況でもその上にハーフモーダルを表示したいからです。
別のモーダルが表示されていたり、モーダルの上にハーフモーダルを表示するには、`ZStack` ではなく、`UIWindowScene`にハーフモーダル用の `UIWindow` を追加する必要があります。
この実装は簡単で、下のような実装で十分です。

```Swift
struct RootView: View { 
    let windowScene: UIWindowScene?

    var resizableSheetCenter: ResizableSheetCenter? {
        windowScene.flatMap(ResizableSheetCenter.resolve(for:))
    }
    
    var body: some View { 
        YOUR_VIEW.environment(\.resizableSheetCenter, resizableSheetCenter)
    }
}
```
これでハーフモーダルを表示するための準備はできました。
では、実際にハーフモーダルを表示しましょう。
ハーフモーダルを表示するコードは次の通りです。

```Swift
struct SomeView: View {
    // ハーフモーダルの状態
    @State var state: ResizableSheetState = .hidden

    var body: some View {
        Button("Show sheet") {
            state = .medium
        }
        .resizableSheet($state) { builder in
            // ハーフモーダルの設定
            builder.content { context in
                Text("text").padding()
            }
        }
    }
}
```

以上です。

この実行結果は次のようになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/c02a9d3f-19fe-7fae-6020-bac683b806a5.gif" width=250pt/>
たったこれだけのコードで、ハーフモーダルを表示することができました。

上のコードの中で、高さの設定をしていないことに気づきましたか？
ResizableSheetには、`hidden`、`medium`、`large`の3つの高さの状態があるのですが、`medium`の時は`content`の高さが最小になるようになっています。
また、`large`の時は`content`の高さに関係なく画面いっぱいに広がります。
この仕組みは非常に強力で、開発者はハーフモーダルに表示したいUIを`content`に記述するだけで、高さが自動で決まります。

もちろん高さを手動で設定することもできます。

```swift
var body: some View {
    Button("Show sheet") {
        state = .medium
    }
    .resizableSheet($state) { builder in
        // ハーフモーダルの設定
        builder.content { context in
            Text("text").frame(height: 300)
        }
    }
}
```

frameで高さを直接指定しています。
この結果は次のようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/3aa5ee14-f9fe-2e06-f3dd-865e03434746.gif" width=250pt/>

## ResizableSheetの構造
ResizableSheetを使いこなすには、Viewの構造を理解する必要があります。

**content :** ハーフモーダルの中身
**sheet background :** ハーフモーダルの背景
**outside :**  ハーフモーダルの外 (デフォルトは`EmptyView`)
**background :** 全ての背景 (デフォルトは半透明の黒のView。タップイベントを持っていて、タップされるとハーフモーダルが閉じます。)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/0fa22152-f621-f14d-83c7-f7b5204076d7.png"/>

これらの値は次のように、builderをメソッドチェーンさせることで実現できます。

```swift
view.resizableSheet($state) { builder in
    builder.content { context in
        Text("text")
            .frame(height: 300)
            .foregroundColor(.white)
    }
    .sheetBackground { context in
        Color.pink
    }
    .background { context in
        EmptyView()
    }
}
```
各クロージャーの引数には`ResizableSheetContext`が渡されます。
この`context`には現在のハーフモーダルに関する情報を持っています。

具体的には次のような情報です。

- 現在の`ResizableSheetState`
- ドラッグした移動距離
- 次の状態へどの程度ドラッグしたのかの割合
- 現在のハーフモーダルの高さ
- ハーフモーダルの最大の高さ

(ちなみに上の実行結果は次のようになります)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/dc79a694-778e-3134-4472-6eebbe9818f2.png" width=250pt/>

またbuilderには、上で説明したようなビュー以外にも以下の値を設定できます。

- サポートする `ResizableSheetState` (サポートされていないstateにはドラッグすることができない)
- ハーフモーダルの角丸
- アニメーション
- ドラッグが終了した時点で次にどの状態に遷移するべきかのロジック

## ResizableSheetの例
ここからはResizableSheetを使ってどのようなことが実現できるのか、コードの例とその実行例を示したいと思います。
細かいコードは省略しているので、詳細な実装を知りたい人はGitHubでリポジトリを公開しているのでそちらを参照してください。
https://github.com/mtj0928/ResizableSheetDemo

### ハーフモーダルの高さに応じてレイアウトを更新する
"Top"と"Bottom"は上下に固定されたまま、ハーフモーダルの高さに応じて灰色のビューの大きさとその透明度が変わる例です。
`context.state`に現在の状態が、`context.progress`に現在の進捗具合が入っています。

```swift
view.resizableSheet($state) { builder in
    builder.content { context in
        VStack(spacing: 0) {
            GrabBar()
            Text("Top")
            Color.gray
                .opacity(
                    context.state == .medium ? max(context.progress, 0) :
                        context.state == .large ? 1 + context.progress : 0
                )
                .allowsHitTesting(false)
            Text("Buttom")
        }
        .padding([.horizontal, .bottom])
    }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/de5a51ac-da89-38a6-6be1-c330d3bac4f3.gif" width=250pt/>

このように色がついたビューを追加したときには、そのビューがドラッグジェスチャーを奪ってしまうので`.allowsHitTesting(false)`を付け足すことを忘れないようにしてください。

### mediumのみのサポート
`supportedState`メソッドにサポートする`ResizableSheetState`を渡すことでサポートする状態を指定できます。
ここで指定していない状態にはドラッグで遷移できません。

```swift
view.resizableSheet($state) { builder in
    builder.content { context in
        VStack {
            HStack {
                Spacer(minLength: 0)
                Button(
                    action: { state = .hidden },
                    label: {
                        Image(systemName: "xmark.circle.fill")
                            .resizable()
                            .foregroundColor(.gray)
                    }
                )
                    .frame(width: 40, height: 40)
            }
            .padding()
            Spacer(minLength: 0).frame(height: 300)
        }
    }
    .supportedState([.medium])
    .background { context in
        Color.black
            .opacity(context.state == .medium ? 0.5 : 0)
            .ignoresSafeArea()
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/8c90720f-5509-a52d-6e79-47bf516b34ba.gif" width=250pt/>

ドラッグではハーフモーダルの状態を変更できないことがわかると思います。

### 呼び出し元のビューも操作可能にする
ここまでの例ではハーフモーダルの後ろに半透明のビューが重なっていて、そのビューがタップジェスチャーを持っているので、呼び出し元のビューを操作できません。
`background`に`EmptyView`を渡すことで、呼び出し元のビューもハーフモーダルも操作できる画面を実現できます。

```swift
struct ParentControllabelSheet: View {
    @State var counter = 0
    @State var state: ResizableSheetState = .hidden

    var body: some View {
        Parent(counter: $counter, state: $state)
            .resizableSheet($state) { builder in
                builder.content { context in
                    SheetContent(counter: $counter)
                        .frame(height: 300)
                }
                .background { context in
                    EmptyView()
                }
            }
    }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/b9f99ba2-42da-9432-2b33-078bb85c136e.gif" width=250pt/>

ハーフモーダルを表示しながら、呼び出し元のボタンをタップできています。
また、呼び出し元のデータとハーフモーダルのデータが同期しています。

### スクロールビューとの同期
ハーフモーダルの中にスクロールビューを配置して、そのスクロールと同期してハーフモーダルの開閉ができる`ResizableScrollView`も用意してあります。
`ResizableScrollView`は`main`と`additional`の2つのビューで構成されています。
ビューが2つに分かれている理由は高さの計算を自動化するためにあります。
`.medium`の時は`main`のみが表示されるように高さが計算されるため、`additional`は表示されません。
`additional`は`.large`のときに表示されます。

```swift
view.resizableSheet($state) { builder in
    builder.content { context in
        ResizableScrollView(
            context: context,
            main: {
                GrabBar().opacity(0)
                ForEach(0..<4) { index in
                    Text("line: \(index)")
                        .padding()
                }
            },
            additional: {
                ForEach(4..<25) { index in
                    Text("line: \(index)")
                        .padding()
                }
            }
        )
            .overlay(
                VStack {
                    GrabBar()
                    Spacer()
                }
            )
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/84c1fdbd-9e97-b3ad-2624-1bc225506732.gif" width=250pt/>

`medium`の時に0~3だけが表示されていることがわかると思います。

### 複数のハーフモーダル
ハーフモーダルを重ねて表示することもできます。
resizableSheetを設定するときに、識別子として文字列を渡すことで実現できます。

```swift
struct MultiSheet: View {
    @State var stateA: ResizableSheetState = .hidden
    @State var stateB: ResizableSheetState = .hidden

    var body: some View {
        Button("Show sheet A") {
            stateA = .medium
        }
        .resizableSheet($stateA, id: "A") { builder in
            builder.content { context in
                Button("Show sheet B") {
                    stateB = .medium
                }.frame(height: 500)
            }
        }
        .resizableSheet($stateB, id: "B") { builder in
            builder.content { context in
                VStack {
                    Button("Hide sheet B") {
                        stateB = .hidden
                    }
                    Button("Hide all sheets") {
                        stateA = .hidden
                        stateB = .hidden
                    }.padding()
                }.frame(height: 300)
            }
        }
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70011/30671b45-4f82-4ace-e3d1-3349b612cb38.gif" width=250pt/>

# おわりに
この記事ではハーフモーダルをSwiftUIで手軽に実現するライブラリ、[ResozableSheet](https://github.com/mtj0928/ResizableSheet)を紹介しました。
カスタマイズ性を残しつつも、手軽に利用できる良いライブラリができたと、個人的には満足しています。

ResizableSheetには他にもアニメーションや角丸など、細かい調整ができますので、ハーフモーダルを実装する際には一度試してみてください。

(良かったらスターください)