iOS/SwiftUI

[iOS / SwiftUI] SwiftUI presentationDetents on iOS 15 and below

TDCIAN 2025. 5. 1. 21:24

 

This posting is based on Kavsoft video:

https://youtu.be/CyMtjSspJZA?si=IxbsPgyOmR5OM9HD

 

SwiftUI presentationDetents on iOS 15 and below

 

As you know, Apple made presentationDetents for developers.

However, developers can use this function from iOS 16.

https://developer.apple.com/documentation/swiftui/view/presentationdetents(_:)

 

presentationDetents(_:) | Apple Developer Documentation

Sets the available detents for the enclosing sheet.

developer.apple.com

 

Including me, many developers have no right to deployment target. Just follow the rule of company. 😭

 

So, I made a wheel. I want to share with developers suffered like me. 🙏

 

As you see,

the content VStack is filled with 4 Rectangles,

each has a height of 50.

The height of the handle area is 16, and the height of the bottom safe area is 34,

so the total contentHeight is 250.

 

 

And now,
the content VStack is filled with 6 Rectangles,

each has a height of 50.

The height of the handle area is 16, and the height of the bottom safe area is 34,

so the total contentHeight is 350.

 

You can config content closure with your own content.

 

 

(1) ContentView.swift

import SwiftUI

struct ContentView: View {
    
    @State private var showBottomSheet: Bool = false
    @State private var bottomSafeAreaHeight: CGFloat = UIApplication.shared.rootViewController?.view.safeAreaInsets.bottom ?? 0
    
    var body: some View {
        ZStack {
            VStack {
                Spacer().frame(height: 50)
                
                Button(action: {
                    withAnimation {
                        showBottomSheet = true
                    }
                }, label: {
                    Text("Show Bottom Sheet")
                        .foregroundStyle(Color.white)
                        .font(.system(size: 16, weight: .bold))
                        .frame(width: 300, height: 50)
                        .background(Color.blue)
                        .clipped()
                        .clipShape(Capsule())
                })
                
                
                Spacer()
            }
            .zIndex(0)
            
            BottomSheetDrawerView(
                showBottomSheet: $showBottomSheet,
                threshold: 3,
                content: { // MARK: Config this closure with your customized content.
                    VStack(spacing: 0) {
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.red)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.blue)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.green)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.yellow)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.orange)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.brown)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.green)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.red)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.blue)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.green)
                        
                        Rectangle()
                            .frame(height: 50)
                            .foregroundStyle(Color.yellow)
                        
                        Spacer()
                            .frame(height: self.bottomSafeAreaHeight)
                    }
                }
            )
            .zIndex(1)
        }
        
    }
}

#Preview {
    ContentView()
}

 

 

(2) BottomSheetDrawerView.swift

import SwiftUI

struct BottomSheetDrawerView<Content: View>: View {
    
    @State private var bottomSheetOffset: CGFloat = 0
    @State private var lastOffset: CGFloat = 0
    @GestureState private var gestureOffset: CGFloat = 0
    @State private var contentHeight: CGFloat = 0

    @Binding var showBottomSheet: Bool
    
    let content: () -> Content
    
    private let threshold: CGFloat
    
    init(
        showBottomSheet: Binding<Bool>,
        threshold: CGFloat = 3,
        @ViewBuilder content: @escaping () -> Content
        
    ) {
        self._showBottomSheet = showBottomSheet
        self.threshold = threshold
        self.content = content
    }
    
    var body: some View {
        Group {
            if showBottomSheet {
                // MARK: Dimmed Background
                Color.black
                    .opacity(0.5)
                    .ignoresSafeArea()
                    .transition(.opacity)
                    .animation(.easeInOut, value: showBottomSheet)
                    .onTapGesture {
                        withAnimation {
                            showBottomSheet = false
                        }
                    }
            }
            
            // MARK: Bottom Sheet
            GeometryReader { proxy in
                let screenHeight = proxy.frame(in: .global).height
                
                ZStack {
                    Color.white
                        .clipShape(
                            CustomCorner(
                                corners: [.topLeft, .topRight],
                                radius: 20
                            )
                        )
                    
                    VStack(spacing: 0) {
                        Capsule()
                            .fill(Color.gray)
                            .frame(width: 80, height: 4)
                            .padding(.top, 12)
                        
                        content()
                    }
                    .background(
                        GeometryReader { contentProxy in
                            Color.clear
                                .preference(key: ContentHeightKey.self, value: contentProxy.size.height)
                        }
                    )
                    .onPreferenceChange(ContentHeightKey.self) { contentHeight in
                        self.contentHeight = contentHeight
                        print("### contentHeight: \(self.contentHeight)")
                    }
                    .frame(maxHeight: .infinity, alignment: .top)
                }
                .offset(y: screenHeight - contentHeight)
                .offset(y: {
                    if showBottomSheet {
                        if -bottomSheetOffset >= 0 {
                            if bottomSheetOffset <= screenHeight {
                                return bottomSheetOffset
                            } else {
                                return screenHeight
                            }
                        } else {
                            return bottomSheetOffset
                        }
                    } else {
                        return (screenHeight) - (screenHeight - contentHeight)
                    }
                }())
                .gesture(
                    DragGesture()
                        .updating(
                            $gestureOffset,
                            body: { value, state, transaction in
                                state = value.translation.height
                                onChangeBottomSheetOffset()
                            }
                        )
                        .onEnded({ value in
                            let thresholdSize: CGFloat = (contentHeight / threshold)
                            
                            withAnimation {
                                if bottomSheetOffset < 0 {
                                    bottomSheetOffset = 0
                                } else {
                                    if bottomSheetOffset > thresholdSize {
                                        bottomSheetOffset = 0
                                        showBottomSheet = false
                                    } else {
                                        bottomSheetOffset = 0
                                    }
                                }
                            }
                            lastOffset = bottomSheetOffset
                        })
                )
            }
            .ignoresSafeArea(.all, edges: .bottom)
            .onChange(of: showBottomSheet) { showBottomSheet in
                if !showBottomSheet {
                    bottomSheetOffset = 0
                    lastOffset = 0
                }
            }
        }
    }
    
    private func onChangeBottomSheetOffset() {
        DispatchQueue.main.async {
            bottomSheetOffset = gestureOffset + lastOffset
        }
    }
    
    private struct CustomCorner: Shape {
        let corners: UIRectCorner
        let radius: CGFloat
        
        func path(in rect: CGRect) -> Path {
            let path = UIBezierPath(
                roundedRect: rect,
                byRoundingCorners: corners,
                cornerRadii: CGSize(
                    width: radius,
                    height: radius
                )
            )
            
            return Path(path.cgPath)
        }
    }
}

fileprivate struct ContentHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

extension UIApplication {
    var rootViewController: UIViewController? {
        return UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .flatMap({ $0.windows })
            .first(where: { $0.isKeyWindow })?
            .rootViewController
    }

}

 

you can download source code here:

https://github.com/TDCIAN/BottomSheetDrawerSwiftUI