最近、ハーフモーダル(セミモーダル)を使っているアプリが増えてきましたね。
僕も個人開発しているアプリでハーフモーダルを使いたい時がよくあるのですが、SwiftUIで手軽に実装する方法がなかったので、 ResizableSheet というライブラリを作りました。
この記事では、ハーフモーダルの実装とその煩雑さ、そして開発したライブラリであるResizableSheetの紹介をします。
(最初の方はハーフモーダルの話をするので、ResizableSheetについて早く知りたい人は 後半から読んでください。笑)
ハーフモーダルとは
iOS標準のマップアプリなどで使われている下から出てくるシートです。
特徴としては、常に画面全体を覆っているわけではなく、 ユーザーがドラッグすることで隠したり画面全体にしたりすることもできます。
iOS標準のアプリ以外にも多くのアプリがこのハーフモーダルを採用していて、今や主流なUIの一つになっています。
ハーフモーダルが使われるようになった背景に、端末の巨大化があると思ってます。
端末が大きくなったことで画面の上にあるUIコンポーネントに親指が届かない、というのはUXとしてよくありません。
そのような問題を解決する方法の一つがこのハーフモーダルです。
画面の上の方に表示しているUIをドラッグで下の方に移動させ、親指だけで操作できます。
実装の煩雑さ
ハーフモーダルは多くのサードパーティーのアプリで使われているだけでなく、Apple自身も使っているので、UIKitやSwiftUIで用意されていそうです。
UISheetPresentationController
がそれに相当します。
しかし、これはiOS15以上でしか使えない上、高さをカスタマイズするといったこともできず、画面いっぱいのサイズかちょうど半分のサイズにしかできません。
なので、少しでも凝ったUIにしようとする自前実装する必要が出てきます。
iOS標準以外にもUIKit向けのサードパーティーのライブラリもあります。
有名どころとしてFloatingPanelがあります。
これは実際かなりパワフルなライブラリでして、カスタマイズ性が非常に高いです。
僕も昔使っていましたし、非常にお世話になったライブラリです。
ただ使っていて大変だったのが、ハーフモーダルの高さを決める実装です。
自分で具体的な値を設定しなければならず、例えばハーフモーダルの中身に応じて高さを変更する場合、そのサイズを計算するコードを実装する必要があり、少し手間でした。
また、個人開発しているアプリの場合、新規で作成するUIのほとんどをSwiftUIで作成しているため、SwiftUIから手軽に使えないというのも少し手間でした。
SwiftUIでハーフモーダルが表示できそうなライブラリとしては、PartialSheetがあります。
これも良さそうなのですがこれはハーフモーダルをドラッグして全画面にする、といったことはできないようになっています。
また、スクロールビューが中にある場合、そのスクロールに合わせてハーフモーダルの開閉ができない仕様なのも少し残念でした。
求めているライブラリ
ここまでのおさらいをしますと、僕が求めているハーフモーダルを表示するライブラリは次のような仕様を満たす必要があります。
- SwiftUIで気軽に呼び出せる
- ハーフモーダルの高さはカスタマイズできるようにしたいが、利用する側で高さの計算をしたくない
- スクロールビューのスクロールで高さが変わる
これらを満たすライブラリとして、ResizableSheetを開発しました。
ResizableSheet
ResizableSheetを開発する上で気をつけたことは、カスタマイズ性を残しつつ、できるだけ少ないコードで開発者がハーフモーダルを表示できるようにすることです。
先にResizableSheetでできることを書いておきます。
- 3つの高さの状態を管理 (
hidden
,medium
,large
) - 各状態に応じてビューの更新が可能
-
medium
の高さを自動で計算 - ドラッグで非表示にすることも可能。できないようにすることも可能。
- スクロールビューのスクロールとの同期
- ハーフモーダルを表示した状態で呼び出し元のViewの操作可 (デフォルトは不可)
- 複数のハーフモーダルを重ねることも可能
簡単な例
まずは簡単なハーフモーダルをResizableSheetで実装してみましょう。
ResizableSheetの実装は次の3つのステップです。
- SwiftPMでResizableSheetをインストールする
- アプリの一番根っことなるViewで
UIWindowScene
をもとにResizableSheetCenter
を用意し、Environmentとして埋め込む - ハーフモーダルを表示したい画面でハーフモーダルを定義し、表示する
1.の説明は省略します。2.で UIWindowScene
が必要な理由は、他のモーダルViewが表示されている状況でもその上にハーフモーダルを表示したいからです。
別のモーダルが表示されていたり、モーダルの上にハーフモーダルを表示するには、ZStack
ではなく、UIWindowScene
にハーフモーダル用の UIWindow
を追加する必要があります。
この実装は簡単で、下のような実装で十分です。
struct RootView: View {
let windowScene: UIWindowScene?
var resizableSheetCenter: ResizableSheetCenter? {
windowScene.flatMap(ResizableSheetCenter.resolve(for:))
}
var body: some View {
YOUR_VIEW.environment(\.resizableSheetCenter, resizableSheetCenter)
}
}
これでハーフモーダルを表示するための準備はできました。
では、実際にハーフモーダルを表示しましょう。
ハーフモーダルを表示するコードは次の通りです。
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()
}
}
}
}
以上です。
この実行結果は次のようになります。
たったこれだけのコードで、ハーフモーダルを表示することができました。
上のコードの中で、高さの設定をしていないことに気づきましたか?
ResizableSheetには、hidden
、medium
、large
の3つの高さの状態があるのですが、medium
の時はcontent
の高さが最小になるようになっています。
また、large
の時はcontent
の高さに関係なく画面いっぱいに広がります。
この仕組みは非常に強力で、開発者はハーフモーダルに表示したいUIをcontent
に記述するだけで、高さが自動で決まります。
もちろん高さを手動で設定することもできます。
var body: some View {
Button("Show sheet") {
state = .medium
}
.resizableSheet($state) { builder in
// ハーフモーダルの設定
builder.content { context in
Text("text").frame(height: 300)
}
}
}
frameで高さを直接指定しています。
この結果は次のようになります。
ResizableSheetの構造
ResizableSheetを使いこなすには、Viewの構造を理解する必要があります。
content : ハーフモーダルの中身
sheet background : ハーフモーダルの背景
outside : ハーフモーダルの外 (デフォルトはEmptyView
)
background : 全ての背景 (デフォルトは半透明の黒のView。タップイベントを持っていて、タップされるとハーフモーダルが閉じます。)
これらの値は次のように、builderをメソッドチェーンさせることで実現できます。
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
- ドラッグした移動距離
- 次の状態へどの程度ドラッグしたのかの割合
- 現在のハーフモーダルの高さ
- ハーフモーダルの最大の高さ
またbuilderには、上で説明したようなビュー以外にも以下の値を設定できます。
- サポートする
ResizableSheetState
(サポートされていないstateにはドラッグすることができない) - ハーフモーダルの角丸
- アニメーション
- ドラッグが終了した時点で次にどの状態に遷移するべきかのロジック
ResizableSheetの例
ここからはResizableSheetを使ってどのようなことが実現できるのか、コードの例とその実行例を示したいと思います。
細かいコードは省略しているので、詳細な実装を知りたい人はGitHubでリポジトリを公開しているのでそちらを参照してください。
https://github.com/mtj0928/ResizableSheetDemo
ハーフモーダルの高さに応じてレイアウトを更新する
"Top"と"Bottom"は上下に固定されたまま、ハーフモーダルの高さに応じて灰色のビューの大きさとその透明度が変わる例です。
context.state
に現在の状態が、context.progress
に現在の進捗具合が入っています。
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])
}
}
このように色がついたビューを追加したときには、そのビューがドラッグジェスチャーを奪ってしまうので.allowsHitTesting(false)
を付け足すことを忘れないようにしてください。
mediumのみのサポート
supportedState
メソッドにサポートするResizableSheetState
を渡すことでサポートする状態を指定できます。
ここで指定していない状態にはドラッグで遷移できません。
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()
}
}
ドラッグではハーフモーダルの状態を変更できないことがわかると思います。
呼び出し元のビューも操作可能にする
ここまでの例ではハーフモーダルの後ろに半透明のビューが重なっていて、そのビューがタップジェスチャーを持っているので、呼び出し元のビューを操作できません。
background
にEmptyView
を渡すことで、呼び出し元のビューもハーフモーダルも操作できる画面を実現できます。
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()
}
}
}
}
ハーフモーダルを表示しながら、呼び出し元のボタンをタップできています。
また、呼び出し元のデータとハーフモーダルのデータが同期しています。
スクロールビューとの同期
ハーフモーダルの中にスクロールビューを配置して、そのスクロールと同期してハーフモーダルの開閉ができるResizableScrollView
も用意してあります。
ResizableScrollView
はmain
とadditional
の2つのビューで構成されています。
ビューが2つに分かれている理由は高さの計算を自動化するためにあります。
.medium
の時はmain
のみが表示されるように高さが計算されるため、additional
は表示されません。
additional
は.large
のときに表示されます。
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()
}
)
}
}
medium
の時に0~3だけが表示されていることがわかると思います。
複数のハーフモーダル
ハーフモーダルを重ねて表示することもできます。
resizableSheetを設定するときに、識別子として文字列を渡すことで実現できます。
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)
}
}
}
}
おわりに
この記事ではハーフモーダルをSwiftUIで手軽に実現するライブラリ、ResozableSheetを紹介しました。
カスタマイズ性を残しつつも、手軽に利用できる良いライブラリができたと、個人的には満足しています。
ResizableSheetには他にもアニメーションや角丸など、細かい調整ができますので、ハーフモーダルを実装する際には一度試してみてください。
(良かったらスターください)