【SwiftUI】sheetをpresentationDetentsでハーフモーダルにする(iOS16)※セミモーダル、ボトムシート

SwiftUI

SwiftUIでsheetをハーフモーダルにする方法について紹介します。

UIKitには先行してiOS15からUISheetPresentationControllerが実装されていましたが、
SwiftUIではiOS16でpresentationDetentsが実装される事で使用可能になりました。

ハーフモーダル(HalfModal)の他にもセミモーダル(SemiModal)やボトムシート(BottmSheet)などの呼び方もあります。
また、半分の必要は特にありませんが、私はハーフモーダルが一番馴染み深いため今回はハーフモーダルと呼称します。

基本となるsheet

SwiftUIではsheet Modifierをハーフモーダル化して使います。
当然ですがfullscreenCoverその名前の通りフルスクリーン限定なのでダメです。

まずはベースとなる通常のsheetを載せておきます。

struct SheetSampleView: View {
    
    @State private var sheet = false
    
    var body: some View {
        VStack {
            Button("Show Sheet"){
                sheet.toggle()
            }
            .sheet(isPresented: $sheet) {
                NavigationStack {
                    VStack {
                        Text("SheetView")
                    }
                    .toolbar {
                        Button("Close", role: .cancel){
                            sheet.toggle()
                        }
                    }
                }
            }
        }

    }
}

基本的なsheetですね。

中身のViewはなんでも良いのですが、Closeボタンは忘れずに実装しておきましょう。
iOS横画面やMacOSの場合はCloseボタンが無いと閉じられません。

sheetの余談

余談ですが、sheetの中と外ではViewの親子関係が途切れてしまうので気をつけましょう。
忘れて@EnvironmentObjectを使うとクラッシュします。

@EnvironmentObject var envObject:EnvObject

//〜中略〜

VStack{
//ここでenvObjectが使えても
}.sheet(isPresented: $sheet) {
    //ここでenvironmentObjectでenvObjectを渡さないとSampleView以下で使えない
    SampleView().environmentObject(envObject)
}

今回の記事ではEnvironmentObjectは使いませんが覚えておきましょう。

sheetをハーフモーダル化する

先程のsheetをハーフモーダル化します。
presentationDetents Modifierを使用します。

struct MediumSheetView: View {
    
    @State private var sheet = false
    
    var body: some View {
        VStack {
            Button("Show Sheet"){
                sheet.toggle()
            }
            .sheet(isPresented: $sheet) {
                NavigationStack {
                    VStack {
                        Text("SheetView")
                    }
                    .toolbar {
                        Button("Close", role: .cancel){
                            sheet.toggle()
                        }
                    }
                }.presentationDetents([.medium])
            }
        }
    }
}

sheet直下のViewにpresentationDetentsを追加しました。

このpresentationDetentsに.mediumを指定しています。
これでハーフモーダルにする事ができます。

この場合はハーフモーダルにしかなりません。
上に引っ張ってもフルサイズに変わることはありません。

presentationDetentsを実装するViewについて

なるべくsheet直下のViewにした方が良いです。
別なViewに分離して中に実装するのは構いませんが、
VStackを挟んでも有効になりますが、NavigationStackを挟むと無効になります。

またsheet表示時に読み込まれないViewに実装した場合も無効になるので、
なるべくsheetに近い階層で実装しましょう。

presentationDetentsが有効になる場合について

このpresentationDetentsは必ず有効になるわけではありません。
iOS横画面やiPadOS全画面、MacOSなどではpresentationDetentsなしのsheetと同じ挙動になります。

具体的に言うと@Environment(\.horizontalSizeClass)がcompactの時に有効になります。
iOS縦画面やiPadOSでSplitViewを使用した際が該当します。

sheetのサイズを可変にする

先程のコードを見て気づいた人もいると思いますが、
presentationDetentsの引数には配列を渡していました。
※正確にはSet<PresentationDetents>です。

複数指定する事で可変のsheetにする事ができます。

.presentationDetents([.medium, .large])

またPresentationDetentsは高さを指定することもできます。

.presentationDetents([.medium
                     ,.large
                     ,.fraction(0.75)
                     ,.height(250)])

fractionでは割合で、heightでは高さを数値で指定する事ができます。
Setなので順序は気にする必要はありません。

CustomPresentationDetent

CustomPresentationDetentプロトコルを実装して独自のPresentationDetentを定義する事も可能です。

以下はAppleのドキュメントに書いてあるコードです。

struct BarDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        max(44, context.maxDetentValue * 0.1)
    }
}

44かmaxDetentValue(sheetの最大サイズ)の十分の一のどちらか大きい方のサイズを返しています。

PresentationDetent.Contextからいくつかの値を取得する事もできます。
とはいえmaxDetentValue以外で使いやすそうな変数へあまりなさそうでした。
色々な計算を必要とした場合に構造体に分けて定義しておくと見やすくなるという使い方になるのかなと思います。

使う時は以下のようにします。

.presentationDetents([.custom(BarDetent.self)])

.customを使用するのですが引数指定がインスタンスではなくTypeです。
なのでここでCustomPresentationDetentを実装した構造体に値を渡せないので少々不便ですね。

sheetの状態を把握する

ハーフモーダルの時には簡易版の表示をして、フルサイズの場合には詳細を表示するなど、
sheetの状態によって表示を分けたい場合もあると思います。

そんな時はpresentationDetentsの第二引数のselectionを使用します。

@State private var presentationDetent = PresentationDetent.medium

///
.presentationDetents([.medium, .large], selection: $presentationDetent)

SwiftUIに慣れている方にはお馴染みですね。
selectionはBindingなので@Stateでマークした変数を渡します。

これでsheetの状態に合わせて変数の値が変化します。

もちろん変数を変更する事でsheetを変化する事ができるので、
ボタンを押すとsheetのサイズが変わるようにする事もできます。

struct SheetStateView: View {
    
    @State private var sheet = false
    @State private var presentationDetent = PresentationDetent.medium
    
    var body: some View {
        VStack {
            Button("Show Sheet"){
                sheet.toggle()
            }
            .sheet(isPresented: $sheet) {
                NavigationStack {
                    VStack {
                        Text("SheetView")
                        Text(presentationDetent == .medium ? "medium" : "large")
                        
                        Button("Change"){
                            presentationDetent = presentationDetent == .medium ? .large : .medium
                        }
                    }
                    .toolbar {
                        Button("Close", role: .cancel){
                            sheet.toggle()
                        }
                    }
                }.presentationDetents([.medium, .large],selection: $presentationDetent)
            }
        }
    }
}

height、fractionやcustomの場合

height、fractionやcustomを指定した場合は全く同じものを指定します。

例えばheightの場合は条件式に.height(200)の様に指定します。
変数から指定した200のような値を取り出す事が出来ない事に注意してください。

またGenericsのままではcaseに使用出来ないので、customを利用する時はifを使うか事前に変数に定義しておくなどしましょう。

struct CustomSheetStateView: View {
    
    @State private var sheet = false
    @State private var presentationDetent = PresentationDetent.medium
    
    let detents:Set<PresentationDetent> = [.custom(BarDetent.self)
                                           ,.height(200)
                                           ,.medium
                                           ,.fraction(0.75)
                                           ,.large]
    
    let bar = PresentationDetent.custom(BarDetent.self)
    
    var body: some View {
        VStack {
            Button("Show Sheet"){
                sheet.toggle()
            }
            .sheet(isPresented: $sheet) {
                NavigationStack {
                    VStack {
//                        if presentationDetent == .custom(BarDetent.self) {
//                            Text("BarDetent")
//                        }
//
                        switch presentationDetent {
                            //Genericsはそのままcaseに使えない
//                        case .custom(BarDetent.self):
//                            Text("BarDetent")
                        case bar:
                            Text("BarDetent")
                        case .height(200)://当然だが数値も一致しないとダメ
                            Text("height(200)")
                        case .medium:
                            Text("medium")
                        case .fraction(0.75):
                            Text("fraction(0.75)")
                        case .large:
                            Text("large")
                        default:
                            EmptyView()
                        }
                    }
                    .toolbar {
                        Button("Close", role: .cancel){
                            sheet.toggle()
                        }
                    }
                }
                .presentationDetents(detents, selection: $presentationDetent)
            }
        }
    }
}

先程も軽く触れましたがpresentationDetentsの第一引数はSetなので変数に定義する場合はSet<PresentationDetent>にします。

注意点

sheetの状態を取得しようとするとPresentationDetentが保持されます。
通常は必ず同じPresentationDetentでsheetが表示されていた所が、
直前に閉じたPresentationDetentで表示される様になります。

どちらが良いかは状況によって異なるので、
毎回同じPresentationDetentで開く様にしたい場合は、
sheetのonDismiss引数を使って開きたいPresentationDetentをセットしましょう。

presentationDetentsを便利に使う

独自のPresentationDetentを定義しておく

毎回heightやfractionの数値を設定するのは面倒ですしミスの原因にもなります。
何度も使うものであればextensionに定義しておきましょう。

先程述べたcustomがそのままcaseで使えない問題への対策にもなります。

extension PresentationDetent{
    static let bar = Self.custom(BarDetent.self)//extensionで用意しておくswitch-caseで使いやすい
    static let height200 = Self.height(200)
    static let fraction075 = Self.fraction(0.75)
}

今回はサンプルだったのでそのままな変数名ですが、
目的に合った名前になっていれば使いやすくミスも少なくなるでしょう。

iOS16未満対応アプリの場合

iOS16未満対応アプリの場合は条件分岐で分ける必要があります。
今回はiOS16未満の時にはpresentationDetentsを適用せずフルサイズのsheetを出す様にします。

presentationDetentsはModifierなので、そのまま分岐させようとするとView毎分岐に含める必要があります。
それでは面倒なので内部で分岐したModifierを用意します。

struct CompatiblePresentationDetents:ViewModifier{
    
    let detents:Set<PresentationDetent>
    @Binding var selection:PresentationDetent
    
    init(_ detents:Set<PresentationDetent>, selection:Binding<PresentationDetent>){
        self.detents = detents
        self._selection = selection
    }
    
    func body(content: Content) -> some View {
        if #available(iOS 16.0, *) {
            content.presentationDetents(detents, selection: $selection)
        } else {
            content
        }
    }
}

こうする事で毎回OSバージョンでの分岐を書く必要がなくなります。
使用する時は以下の様になります。

.modifier(CompatiblePresentationDetents(detents, selection: $presentationDetent))

presentationDetentsの代わりにmodifierを使用して用意したViewModifierを渡します。

長くて面倒という場合はViewにextensionを追加する事でpresentationDetentsの様に使う事もできます。

extension View{
    func compatibleDetents(_ detents:Set<PresentationDetent>
                           ,selection:Binding<PresentationDetent>) -> some View{
        modifier(CompatiblePresentationDetents(detents, selection: selection))
    }
}

///使う時
.compatibleDetents(detents, selection: $presentationDetent)

最後に

このようにiOS16からSwiftUIでハーフモーダルが利用できる様になりました。
高さを変えたり状態を把握したりなどをSwiftUIらしい方法で行えるので非常に便利です。

新機能なのでOSバージョンによる制限はありますが、
対応バージョンのみ有効にするという形なら実装も楽なので是非使ってみてください。

おまけ

今回のコードをまとめたものです。

struct BarDetent: CustomPresentationDetent {
    static func height(in context: Context) -> CGFloat? {
        max(44, context.maxDetentValue * 0.1)
    }
}

extension PresentationDetent{
    static let bar = Self.custom(BarDetent.self)//extensionで用意しておくswitch-caseで使いやすい
    static let height200 = Self.height(200)
    static let fraction075 = Self.fraction(0.75)
}

struct SheetExtraView: View {
    
    @State private var sheet = false
    @State private var presentationDetent = PresentationDetent.height200
    
    let detents:Set<PresentationDetent> = [.bar
                                           ,.height200
                                           ,.medium
                                           ,.fraction075
                                           ,.large]
    
    var body: some View {
        VStack {
            Button("Show Sheet"){
                sheet.toggle()
            }
            .sheet(isPresented: $sheet,onDismiss: {
                presentationDetent = .height200
            }) {
                NavigationStack {
                    VStack {
                        switch presentationDetent {
                        case .bar:
                            Text("BarDetent")
                        case .height200://当然だが数値も一致しないとダメ
                            Text("height(200)")
                        case .medium:
                            Text("medium")
                        case .fraction075:
                            Text("fraction(0.75)")
                        case .large:
                            Text("large")
                        default:
                            EmptyView()
                        }
                    }
                    .toolbar {
                        Button("Close", role: .cancel){
                            sheet.toggle()
                        }
                    }
                }
//                .presentationDetents(detents, selection: $presentationDetent)
//                .modifier(CompatiblePresentationDetents(detents, selection: $presentationDetent))
                .compatibleDetents(detents, selection: $presentationDetent)
            }
        }
    }
}

struct CompatiblePresentationDetents:ViewModifier{
    
    let detents:Set<PresentationDetent>
    @Binding var selection:PresentationDetent
    
    init(_ detents:Set<PresentationDetent>, selection:Binding<PresentationDetent>){
        self.detents = detents
        self._selection = selection
    }
    
    func body(content: Content) -> some View {
        if #available(iOS 16.0, *) {
            content.presentationDetents(detents, selection: $selection)
        } else {
            content
        }
    }
}

extension View{
    func compatibleDetents(_ detents:Set<PresentationDetent>
                           ,selection:Binding<PresentationDetent>) -> some View{
        modifier(CompatiblePresentationDetents(detents, selection: selection))
    }
}

コメント

タイトルとURLをコピーしました