Xaxxus
Xaxxus

Reputation: 1809

SwiftUI: Unable to scroll when custom bottom sheet is half way open

So I have built a custom bottom sheet (similar to apple maps) in swiftUI. The sheet seems to work fine in most conditions but there is some weird behaviour in the following conditions:

  1. the sheet is presented over top of a view with scrollable content
  2. the sheet itself has scrollable content
  3. the sheet is open half way.

Basically in this situation, the any attempts to scroll the sheet content pass through to the view behind.

If you swipe close to the center of the screen, sometimes you can get the sheet's content to scroll.

Here is a view that exhibits the strange behaviour:

import SwiftUI

struct ContentView: View {
    @State var presentSheet: Bool = false
    
    var body: some View {
        ZStack{
            VStack {
                List {
                    ForEach(0...100, id: \.self) { (number) in
                        Text("number: \(number)")
                    }
                }
                
                Button {
                    presentSheet = true
                } label: {
                    Text("Open Sheet")
                }
            }
            // Steps to produce bug:
            // 1. Tap Open Sheet Button
            // 2. When sheet is opened to half height, scrolling gestures pass through to the view behind.
            .bottomSheet(isPresented: $presentSheet) {
                List {
                    ForEach(0...100, id: \.self) { (number) in
                        Text("number: \(number)")
                    }
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Here is my sheet implementation:

import SwiftUI

public struct BottomSheet<Content: View>: View {
    public enum SheetState {
        case halfPage
        case fullPage
        case minimized
    }
    
    var content: Content
    var onDismiss: () -> Void
    
    @Binding var isPresented: Bool
    @State private var sheetState: Self.SheetState = .halfPage
    @GestureState private var dragState: CGFloat = .zero
    
    public init(
        isPresented: Binding<Bool>,
        onDismiss: @escaping () -> Void = {},
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._isPresented = isPresented
        self.content = content()
        self.onDismiss = onDismiss
    }
    
    public var body: some View {
        if isPresented {
            VStack(spacing: .zero) {
                ZStack {
                    Color.white
                        .frame(maxWidth: .infinity)
                        .frame(height: .barHeight)
                        .gesture(dragGesture)
                    
                    DragIndicator()
                        .frame(maxWidth: .infinity)
                        .padding(.bottom)
                        .frame(height: .barHeight, alignment: .center)
                    
                    HStack {
                        Spacer()
                        Button {
                            dismissSheet()
                        } label: {
                            Text("Close")
                        }
                        .padding(8)
                        .padding(.horizontal, 8)
                    }
                }
                
                content.frame(maxHeight: .infinity)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .frame(height: sheetHeight - dragState, alignment: .top)
            .background(Color.white)
            .cornerRadius(16)
            .frame(height: UIScreen.main.bounds.height, alignment: .bottom)
            .onDisappear {
                onDismiss()
                sheetState = .halfPage
            }
            .shadow(radius: 4)
            .transition(.move(edge: .bottom))
            .animation(.interactiveSpring(), value: dragState)
            .edgesIgnoringSafeArea(.bottom)
        }
    }
    
    private func dismissSheet() {
        withAnimation { isPresented = false }
    }
    
    private var sheetHeight: CGFloat {
        switch sheetState {
        case .fullPage:
            return .maxHeight
        case .halfPage:
            return .midHeight
        case .minimized:
            return .minHeight
        }
    }
    
    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 1.0, coordinateSpace: .global)
            .updating($dragState) { (value, state, transaction) in
                state = value.translation.height
            }
            .onEnded { (value) in
                let swipe = value.translation.height
                
                switch sheetState {
                case .fullPage:
                    if swipe.isLargeDownSwipe {
                        sheetState = .minimized
                    } else if swipe.isDownSwipe {
                        sheetState = .halfPage
                    }
                case .halfPage:
                    if swipe.isUpSwipe {
                        sheetState = .fullPage
                    } else if swipe.isDownSwipe {
                        sheetState = .minimized
                    }
                case .minimized:
                    if swipe.isLargeUpSwipe {
                        sheetState = .fullPage
                    } else if swipe.isUpSwipe {
                        sheetState = .halfPage
                    }
                }
            }
    }
}

public struct BottomSheetViewModifier<SheetContent: View>: ViewModifier {
    var sheetContent: SheetContent
    var onDismiss: () -> Void
    @Binding var isPresented: Bool
    
    public init(isPresented: Binding<Bool>, onDismiss: @escaping () -> Void = {}, @ViewBuilder sheetContent: @escaping () -> SheetContent) {
        self._isPresented = isPresented
        self.onDismiss = onDismiss
        self.sheetContent = sheetContent()
    }
    
    public func body(content: Content) -> some View {
        ZStack {
            content
                .zIndex(0)
            
            BottomSheet(isPresented: $isPresented, onDismiss: onDismiss) {
                sheetContent
                    .layoutPriority(.greatestFiniteMagnitude)
            }
            .zIndex(1)
        }
    }
}

public extension View {
    
    /// Show a bottom sheet.
    /// - Parameters:
    ///   - isPresented: Is the sheet presented or not
    ///   - onDismiss: code to run when the sheet is dismissed
    ///   - content: the view to show on the sheet
    /// - Returns: The view with a bottom sheet view modifier
    func bottomSheet<Content: View>(
        isPresented: Binding<Bool>,
        onDismiss: @escaping () -> Void = {},
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        self.modifier(
            BottomSheetViewModifier(
                isPresented: isPresented,
                onDismiss: onDismiss,
                sheetContent: content
            )
        )
    }
}

fileprivate extension CGFloat {
    static let barHeight = CGFloat(44)
    static let maxHeight = UIScreen.main.bounds.height * 0.9
    static let midHeight = UIScreen.main.bounds.height * 0.6
    static let minHeight = UIScreen.main.bounds.height * 0.1
    
    var isLargeUpSwipe: Bool { self <= -300 }
    var isLargeDownSwipe: Bool { self >= 300 }
    var isUpSwipe: Bool { self <= -50 }
    var isDownSwipe: Bool { self >= 50 }
}

struct BottomSheet_Previews: PreviewProvider {
    static var previews: some View {
        BottomSheet(isPresented: .constant(true)) {
            Text("Hello World")
        }
    }
}

/// Simple view for a drag indicator. Commonly used on the top of sheets to indicate you can drag to dismiss.
public struct DragIndicator: View {
    public static var width: CGFloat = 40
    public static var height: CGFloat = 6
    
    public var body: some View {
        Capsule()
            .fill(Color.gray.opacity(0.3))
            .frame(width: Self.width, height: Self.height)
    }
}

Does anyone have any idea why this is happening?

I suspect it's a bug with SwiftUI. As everything works fine when the sheet is expanded to its maximum size.

Upvotes: 0

Views: 1834

Answers (3)

Reid Ellis
Reid Ellis

Reputation: 4054

Building Bottom Sheet in SwiftUI by Majid might be relevant to this discussion..

Upvotes: -1

Xaxxus
Xaxxus

Reputation: 1809

Looking at Raja Kishan's advice about using the frame to position the sheet lead me down a bit of a rabbit hole of cleaning up the layout.

Heres the new code. It now works.

Im sure it can be cleaned up further though.

import SwiftUI

public struct BottomSheet<Content: View>: View {
    public enum SheetState {
        case halfPage
        case fullPage
        case minimized
    }
    
    private var content: Content
    private var onDismiss: () -> Void
    private var showsBar: Bool
    
    @Binding private var isPresented: Bool
    @State private var sheetState: Self.SheetState = .halfPage
    @GestureState private var dragState: CGFloat = .zero
    
    public init(
        isPresented: Binding<Bool>,
        showsBar: Bool = true,
        onDismiss: @escaping () -> Void = {},
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._isPresented = isPresented
        self.showsBar = showsBar
        self.content = content()
        self.onDismiss = onDismiss
    }
    
    public var body: some View {
        if isPresented {
            VStack(spacing: 0) {
                ZStack(alignment: .top) {
                    if !showsBar {
                        content.frame(maxHeight: .infinity)
                            .zIndex(0)
                    }
                    
                    Color.colorSurface
                        // making something clear seems to prevent touches from registering.
                        .opacity(0.00000001)
                        .frame(maxWidth: .infinity)
                        .frame(height: .barHeight)
                        .highPriorityGesture(dragGesture)
                        .zIndex(1)
                    
                    DragIndicator()
                        .frame(maxWidth: .infinity)
                        .padding(.bottom)
                        .frame(height: .barHeight, alignment: .center)
                        .zIndex(2)
                    
                    HStack {
                        Spacer()
                        Button {
                            dismissSheet()
                        } label: {
                            Text("Done")
                        }
                        .padding(8)
                        .padding(.horizontal, 8)
                    }
                    .zIndex(3)
                }
                
                if showsBar {
                    content.frame(maxHeight: .infinity)
                }
            }
            .frame(height: sheetHeight - dragState, alignment: .top)
            .background(.white)
            .cornerRadius(16)
            .onDisappear {
                onDismiss()
                sheetState = .halfPage
            }
            .shadow(radius: 6)
            .transition(.move(edge: .bottom))
            .animation(.interactiveSpring(), value: dragState)
        }
    }
    
    private func dismissSheet() {
        withAnimation { isPresented = false }
    }
    
    private var sheetHeight: CGFloat {
        switch sheetState {
        case .fullPage:
            return .maxHeight
        case .halfPage:
            return .midHeight
        case .minimized:
            return .minHeight
        }
    }
    
    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 1.0, coordinateSpace: .global)
            .updating($dragState) { (value, state, _) in
                state = value.translation.height
            }
            .onEnded { (value) in
                let swipe = value.translation.height
                
                switch sheetState {
                case .fullPage:
                    if swipe.isLargeDownSwipe {
                        sheetState = .minimized
                    } else if swipe.isDownSwipe {
                        sheetState = .halfPage
                    }
                case .halfPage:
                    if swipe.isUpSwipe {
                        sheetState = .fullPage
                    } else if swipe.isDownSwipe {
                        sheetState = .minimized
                    }
                case .minimized:
                    if swipe.isLargeUpSwipe {
                        sheetState = .fullPage
                    } else if swipe.isUpSwipe {
                        sheetState = .halfPage
                    }
                }
            }
    }
}

public struct BottomSheetViewModifier<SheetContent: View>: ViewModifier {
    private var showsBar: Bool
    private var sheetContent: () -> SheetContent
    private var onDismiss: () -> Void
    @Binding var isPresented: Bool
    
    public init(
        isPresented: Binding<Bool>,
        showsBar: Bool = true,
        onDismiss: @escaping () -> Void = {},
        @ViewBuilder sheetContent: @escaping () -> SheetContent
    ) {
        self._isPresented = isPresented
        self.showsBar = showsBar
        self.onDismiss = onDismiss
        self.sheetContent = sheetContent
    }
    
    public func body(content: Content) -> some View {
        ZStack(alignment: .bottom) {
            content
                .zIndex(0)
            
            BottomSheet(
                isPresented: $isPresented,
                showsBar: showsBar,
                onDismiss: onDismiss,
                content: sheetContent
            )
            .zIndex(1)
        }
        .edgesIgnoringSafeArea(.all)
    }
}

public extension View {
    
    /// Show a bottom sheet.
    /// - Parameters:
    ///   - isPresented: Is the sheet presented or not
    ///   - showsBar: whether the view is inset at the top
    ///   - onDismiss: code to run when the sheet is dismissed
    ///   - content: the view to show on the sheet
    /// - Returns: The view with a bottom sheet view modifier
    func bottomSheet<Content: View>(
        isPresented: Binding<Bool>,
        showsBar: Bool = true,
        onDismiss: @escaping () -> Void = {},
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        self.modifier(
            BottomSheetViewModifier(
                isPresented: isPresented,
                showsBar: showsBar,
                onDismiss: onDismiss,
                sheetContent: content
            )
        )
    }
}

fileprivate extension CGFloat {
    static let barHeight = CGFloat(44)
    static let maxHeight = UIScreen.main.bounds.height * 0.9
    static let midHeight = UIScreen.main.bounds.height * 0.6
    static let minHeight = UIScreen.main.bounds.height * 0.1
    
    var isLargeUpSwipe: Bool { self <= -300 }
    var isLargeDownSwipe: Bool { self >= 300 }
    var isUpSwipe: Bool { self <= -50 }
    var isDownSwipe: Bool { self >= 50 }
}

Upvotes: -1

Raja Kishan
Raja Kishan

Reputation: 18914

First, remove this line

.frame(height: UIScreen.main.bounds.height, alignment: .bottom)

from the BottomSheet body.

And, Give the bottom alignment to the ZStack of the BottomSheetViewModifier.

public struct BottomSheetViewModifier<SheetContent: View>: ViewModifier {
    var sheetContent: SheetContent
    var onDismiss: () -> Void
    @Binding var isPresented: Bool
    
    public init(isPresented: Binding<Bool>, onDismiss: @escaping () -> Void = {}, @ViewBuilder sheetContent: @escaping () -> SheetContent) {
        self._isPresented = isPresented
        self.onDismiss = onDismiss
        self.sheetContent = sheetContent()
    }
    
    public func body(content: Content) -> some View {
        ZStack(alignment: .bottom) { // <--- Here
            content
                .zIndex(0)
            
            BottomSheet(isPresented: $isPresented, onDismiss: onDismiss) {
                sheetContent
                    .layoutPriority(.greatestFiniteMagnitude)
            }
            .zIndex(1)
        }
    }
}

Upvotes: 1

Related Questions