Reputation: 1809
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:
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
Reputation: 4054
Building Bottom Sheet in SwiftUI by Majid might be relevant to this discussion..
Upvotes: -1
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
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