geDemのアバターアイコン
geDem
 Blog
記事のカバー写真

【SwiftUIのみ】[iOS15・16]高さが可変、背景操作可能なシートを作る

geDemのアバターアイコン
geDem
ペン

マップアプリとかだと、マップが全画面に表示されて、その下にシートが表示されているのをよく見かけますよね。

マップアプリ

要件としては以下のような感じ。

  • シートの高さが可変であること
  • 背景(シートの下のView)操作が可能であること
  • シートを一番下まで下げてもシート自体は閉じない(最小サイズになる)

ただ、高さを可変にするためのpresentationDetents(_:)はiOS16からだし、

シート下の背景操作をするためのpresentationBackgroundInteraction(_:)はiOS16.4〜です・・・

UIKitの自信はないなぁ、、ということで自作してみました!


すでに、高さ指定が可能なシートの実装方法についての記事はあったため、こちらを参考にしました。(本当に助かりました。)

https://qiita.com/hayatehhh0704/items/90e41ff89d426811289c

できたもの

高さが可変のシート

コード

ContentView.swiftstruct ContentView: View {
    @State var isShowSheet: Bool = false
    
    var body: some View {
        VStack {
            Button {
                isShowSheet.toggle()
            } label: {
                Text("シート表示/非表示")
            }
        }
        .multiHeightSheet(isPresented: $isShowSheet, 2) {
            VStack {
                Text("複数の高さのシート")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color(.systemGray5))
        }
    }
}

カスタムモディファイアを作成し、普通のSheetっぽく使えるようにしています。

extension View {
    func multiHeightSheet(
        isPresented: Binding<Bool>,
        detents: Set<CGFloat>,
        @ViewBuilder content: () -> some View
    ) -> some View {
        ZStack {
            self
            
            MultiHeightSheet(isPresented: isPresented, detents: detents, content: content)
        }
    }
}
MultiHeightSheet.swiftstruct MultiHeightSheet<Content: View>: View {
    @Binding var isPresented: Bool
    @State private var offsetY: CGFloat = 0
    @GestureState private var dragState: CGSize = .zero
    
    let detents: [CGFloat]
    let maxHeight: CGFloat
    let content: Content
    
    init(
        isPresented: Binding<Bool>,
        detents: Set<CGFloat>,
        @ViewBuilder content: () -> Content
    ) {
        self._isPresented = isPresented
        // シートの高さの指定を小→大に並び替える
        self.detents = detents.sorted()
        // 指定のうち一番高い値を取得する
        self.maxHeight = self.detents.last ?? 0
        self.content = content()
    }
    
    var viewOffsetY: CGFloat {
        // View上でのシートの高さを更新する
        // シートの高さが最大の高さより高くならないようにする
        let newOffset = offsetY + dragState.height
        if newOffset < 0 { return 0 }
        
        return newOffset
    }
    
    var dragGesture: some Gesture {
        DragGesture()
            .updating($dragState) { value, state, _ in
                // ドラッグ中
                state = value.translation
            }
            .onEnded { value in
                // ドラッグ終了時
                // 現在のシートの高さを取得
                let currentHeight = maxHeight - offsetY - value.translation.height
                
                // detents、detentsの指定の数で割って、現在の高さが最も近い範囲の高さにする
                let maxHeightInt = Int(maxHeight)
                let currentHeightInt = Int(currentHeight)
                let boundary = maxHeightInt / detents.count
                
                for i in 0..<detents.count {
                    let maxLimit = detents.last == detents[i] ? Int.max : (boundary * (1 + i))
                    
                    if (boundary * i)..<maxLimit ~= currentHeightInt {
                        offsetY = maxHeight - detents[i]
                        return
                    }
                }
            }
    }
    
    var body: some View {
        ZStack(alignment: .bottom) {
            if isPresented {
                content
                    .frame(maxWidth: .infinity, maxHeight: maxHeight)
                    // シート上の角丸
                    .clipShape(.rect(topLeadingRadius: 20, topTrailingRadius: 20))
                    .offset(y: viewOffsetY)
                    .gesture(dragGesture)
                    .transition(.opacity.combined(with: .move(edge: .bottom)))
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
        .ignoresSafeArea()
        .animation(.easeInOut, value: isPresented)
    }
}

解説

文系の私は、ドラッグジェスチャで高さを調整するロジックを考えるのに苦労したので、メモ程度に解説します。

var dragGesture: some Gesture {
    DragGesture()
        .updating($dragState) { value, state, _ in
            // ドラッグ中
            state = value.translation
        }
        .onEnded { value in
            // ドラッグ終了時
            // 現在のシートの高さを取得
            let currentHeight = maxHeight - offsetY - value.translation.height
            
            // detents、detentsの指定の数で割って、現在の高さが最も近い範囲の高さにする
            let maxHeightInt = Int(maxHeight)
            let currentHeightInt = Int(currentHeight)
            let boundary = maxHeightInt / detents.count
            
            for i in 0..<detents.count {
                let maxLimit = detents.last == detents[i] ? Int.max : (boundary * (1 + i))
                
                if (boundary * i)..<maxLimit ~= currentHeightInt {
                    offsetY = maxHeight - detents[i]
                    return
                }
            }
        }
}

まずはこんな感じでシートをdetentsの指定の数で割ります。(今回は3つ指定してるので3で割ります。)

実装のイメージ

ドラッグが終了した時点で、シートの高さがこのうちのいづれかの範囲にあるとき、シートの高さをそこの番号の高さに設定すれば良さそうです。(detentsは初期化時に小さい順にソート済み)

for i in 0..<detents.count {
    let maxLimit = detents.last == detents[i] ? Int.max : (boundary * (1 + i))
    
    if (boundary * i)..<maxLimit ~= currentHeightInt {
        offsetY = maxHeight - detents[i]
        return
    }
}

指定したい高さが見つかったら、その高さまでシートを移動させます。

offsetYは「シートの最大の高さの位置から、シートがどれだけ下に移動しているか」です。

言い換えると「offSetY==maxHeight」の場合、シートが画面の一番下にある(見えない)状態です。

つまり、maxHeightから、指定したい高さを引けば、その高さになってくれます。

🙂‍↕️最後まで読んでいただきありがとうございます🙂‍↕️