Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
//
// BottomSheetView.swift
//
// Created by Majid Jabrayilov
// Copyright © 2019 Majid Jabrayilov. All rights reserved.
//
import SwiftUI
fileprivate enum Constants {
static let radius: CGFloat = 16
static let indicatorHeight: CGFloat = 6
static let indicatorWidth: CGFloat = 60
static let snapRatio: CGFloat = 0.25
static let minHeightRatio: CGFloat = 0.3
}
struct BottomSheetView<Content: View>: View {
@Binding var isOpen: Bool
let maxHeight: CGFloat
let minHeight: CGFloat
let content: Content
@GestureState private var translation: CGFloat = 0
private var offset: CGFloat {
isOpen ? 0 : maxHeight - minHeight
}
private var indicator: some View {
RoundedRectangle(cornerRadius: Constants.radius)
.fill(Color.secondary)
.frame(
width: Constants.indicatorWidth,
height: Constants.indicatorHeight
).onTapGesture {
self.isOpen.toggle()
}
}
init(isOpen: Binding<Bool>, maxHeight: CGFloat, @ViewBuilder content: () -> Content) {
self.minHeight = maxHeight * Constants.minHeightRatio
self.maxHeight = maxHeight
self.content = content()
self._isOpen = isOpen
}
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
self.indicator.padding()
self.content
}
.frame(width: geometry.size.width, height: self.maxHeight, alignment: .top)
.background(Color(.secondarySystemBackground))
.cornerRadius(Constants.radius)
.frame(height: geometry.size.height, alignment: .bottom)
.offset(y: max(self.offset + self.translation, 0))
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.height
}.onEnded { value in
let snapDistance = self.maxHeight * Constants.snapRatio
guard abs(value.translation.height) > snapDistance else {
return
}
self.isOpen = value.translation.height < 0
}
)
}
}
}
struct BottomSheetView_Previews: PreviewProvider {
static var previews: some View {
BottomSheetView(isOpen: .constant(false), maxHeight: 600) {
Rectangle().fill(Color.red)
}.edgesIgnoringSafeArea(.all)
}
}
@ChrisParkerWA

This comment has been minimized.

Copy link

@ChrisParkerWA ChrisParkerWA commented Jan 1, 2020

Thank you so much for sharing this Majid. I have used it on a little App that is the last Project (Day 99) as part of Paul Hudsons 100 Days of SwiftUI. it works an absolute treat though I made a a couple of changes to customise it for my needs.
Simulator Screen Shot - iPhone 11 - 2020-01-01 at 15 22 04

@JasonGreenAmerica

This comment has been minimized.

Copy link

@JasonGreenAmerica JasonGreenAmerica commented Apr 28, 2020

When I call this from a Button the below the result. The blank screen is a sheet with your sheet inside. This is not the expected.

.sheet(isPresented: self.$showingActionsheet) {
BottomSheetView(
isOpen: self.$showingActionsheet,
maxHeight: geo.size.height * 0.7
) {
Rectangle().fill(Color.red)
}.edgesIgnoringSafeArea(.all)

Simulator Screen Shot - iPhone 11 Pro Max - 2020-04-28 at 07 06 45

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Apr 28, 2020

@JasonGreenAmerica you don't need to use .sheet to present BottomSheet.

@JasonGreenAmerica

This comment has been minimized.

Copy link

@JasonGreenAmerica JasonGreenAmerica commented Apr 28, 2020

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Apr 28, 2020

@JasonGreenAmerica take a look at my post, there is the usage example in the end.
https://swiftwithmajid.com/2019/12/11/building-bottom-sheet-in-swiftui/

@landtanin

This comment has been minimized.

Copy link

@landtanin landtanin commented May 31, 2020

Hey @mecid, love this gist. Thanks so much for sharing this. In my use cases, the implicit animation dictates all views with explicit animation on the sheet. So I updated it to use explicit animation. I really wanted to contribute to this gist. But looks like I can't so I've forked and modified it here https://gist.github.com/landtanin/dc8bc52f77345fd078deb64f91393008. Just want to let you know and sort of contribute back. Thanks again!

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Jun 3, 2020

@landtanin Thanks for your contribution 🙏🏻

@mxgc

This comment has been minimized.

Copy link

@mxgc mxgc commented Jun 7, 2020

@mecid Thank you so much for making this gist available!

But when I put a Slider inside the BottomSheetView, the slider control would interact with the drag state of the BottomSheetView. Really appreciate it if you could point me in the right direction of solving this problem!

@toby-j

This comment has been minimized.

Copy link

@toby-j toby-j commented Jun 13, 2020

Hi, I can't figure out how to add content to the slide. From the looks of it, I need to give 'content' a body as 'content' has restrains to only show content on the slide.

@toby-j

This comment has been minimized.

Copy link

@toby-j toby-j commented Jun 14, 2020

Nevermind, think I've worked it out. I've basically made another var like indicator which'll have my custom view in it.

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Jun 14, 2020

@mxgc I'm not sure there is a way to control it for now.

@mykeyismyname

This comment has been minimized.

Copy link

@mykeyismyname mykeyismyname commented Jul 14, 2020

@mxgc I was able to get it working with a Slider. Basically we need to track isSliding state in our vm. So the BottomSheet initializer will look something like this depending on your adaptation:
init(isOpen: Binding<Bool>, @ViewBuilder content: () -> Content, isSliding: Binding<Bool> = .constant(false)). Whenever the sheet content contains a slider we need to pass in isSliding. Slider has an onEditingChanged which we can use like this: self.vm.isSliding = $0. Then in bottom sheet, we can have:

func dragMask() -> GestureMask { if !isOpen { return .subviews } if isOpen && isSliding { return .none } return .all } .... let drag = DragGesture(minimumDistance: 10) .... .gesture(drag, including: dragMask())
/// Note: Don't forget the minimum distance, 10 seems to be enough, but without it, the slider won't even get a chance to start.

@javariahazoor

This comment has been minimized.

Copy link

@javariahazoor javariahazoor commented Aug 28, 2020

How to open bottom sheet on button click?

@mecid

This comment has been minimized.

Copy link
Owner Author

@mecid mecid commented Aug 28, 2020

@javariahazoor toggle your isOpen boolean.

@matarali

This comment has been minimized.

Copy link

@matarali matarali commented Sep 22, 2020

Could you provide an example of how to toggle isOpen Boolen from another swift file?

@xuanzi23

This comment has been minimized.

Copy link

@xuanzi23 xuanzi23 commented Oct 6, 2020

@javariahazoor How u success to make it?

@jonator

This comment has been minimized.

Copy link

@jonator jonator commented Oct 6, 2020

@matarali since it is a binding you simply change the property. If it needs to be changed in another file then likely the property will be inside an ObservableObject.

@barrylachapelle

This comment has been minimized.

Copy link

@barrylachapelle barrylachapelle commented Dec 29, 2020

Fantastic! Thank you.

@contracorner

This comment has been minimized.

Copy link

@contracorner contracorner commented Jan 19, 2021

How do I inset the main content view to counteract edgesIgnoringSafeArea? I tried adding padding based on the geometry data:

var body: some View {
    GeometryReader { geometry in
        VStack {
            Button(action: { bottomSheetShown.toggle() }, label: {
                Text("\(bottomSheetShown ? "Close" : "Open") Sheet")
            })
            .padding()
            Button(action: { hideSheet.toggle() }, label: {
                Text("\(hideSheet ? "Unhide" : "Hide") Sheet")
            })
            .padding()
        }
        .padding(.top, geometry.safeAreaInsets.top)
        .padding(.leading, geometry.safeAreaInsets.leading)
        .padding(.trailing, geometry.safeAreaInsets.trailing)
        .background(Color.green)
        if !hideSheet {
            BottomSheetView(
                isOpen: self.$bottomSheetShown,
                maxHeight: geometry.size.height * 0.7
            ) {
                Color.blue
            }
        }
    }.edgesIgnoringSafeArea(.all)
}
@contracorner

This comment has been minimized.

Copy link

@contracorner contracorner commented Jan 19, 2021

I solved this. I wrapped an extra GeometryReader (ugh?) and used its safeAreaInsets for the main content padding. The inner GeometryReader seems to mainly serve the purpose of being a greedy view to use up all the available space, and it ignores the safe area.

So: GR1 reads the safe area. GR2 Ignores the safe area. VStack{}.padding(geometry.safeAreaInsets) re-honors the GR1 safe area.

GeometryReader { geometry in
    GeometryReader { _ in
        VStack {
                // Your main content here [...]
        }
        .padding(geometry.safeAreaInsets)
        BottomSheetView1(
            isOpen: self.$bottomSheetShown,
            maxHeight: geometry.size.height * 0.7
        ) {
            // Your partial sheet content here [...]
        }
    }
    .edgesIgnoringSafeArea(.all)
    .background(Color.green)
}
@chatumoh

This comment has been minimized.

Copy link

@chatumoh chatumoh commented Jan 27, 2021

@mecid The code for bottom sheet does not behave as apple maps sheet i.e. On first snap(swipe up) opens up on half the screen height and cover full screen on second snap based on max height. Can you suggest if i am missing anything ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment