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)
}
}
@javariahazoor
Copy link

javariahazoor commented Aug 28, 2020

How to open bottom sheet on button click?

@mecid
Copy link
Author

mecid commented Aug 28, 2020

@javariahazoor toggle your isOpen boolean.

@matarali
Copy link

matarali commented Sep 22, 2020

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

@xuanzi23
Copy link

xuanzi23 commented Oct 6, 2020

@javariahazoor How u success to make it?

@jonator
Copy link

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
Copy link

barrylachapelle commented Dec 29, 2020

Fantastic! Thank you.

@contracorner
Copy link

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
Copy link

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
Copy link

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 ?

@X901
Copy link

X901 commented Mar 31, 2021

I notice some issues

  1. the BottomSheetView and its view will create with parent view
  2. it won't be destroy (removed from memory) after close it

is there a way to create it only when tab on button
and after close it removed from memory ?

@lahariganti
Copy link

lahariganti commented Jun 29, 2021

integration with UIKit . . . plausible?

  • want to present this (SwiftUI bottom sheet) view but end up with this non-modal / resizable sheet inside another sheet due to my current setup
  • is there a way to control the height of the presented view? how do I go about this? 😕
let socialSharingView = UIHostingController(rootView: ContentView())
self.present(socialSharingView, animated: true, completion: nil)

@X901
Copy link

X901 commented Jul 5, 2021

integration with UIKit . . . plausible?

  • want to present this (SwiftUI bottom sheet) view but end up with this non-modal / resizable sheet inside another sheet due to my current setup
  • is there a way to control the height of the presented view? how do I go about this? 😕
let socialSharingView = UIHostingController(rootView: ContentView())
self.present(socialSharingView, animated: true, completion: nil)

you don't need to use it with UIKit , you can use https://github.com/scenee/FloatingPanel

@svachmic
Copy link

svachmic commented Jul 19, 2021

Great stuff! Just one question - when I add a button to the sheet and then I open the sheet, the button visually moves, but the tap gesture seems to stay at the same place. Nothing fancy there - just an ordinary Button. Same thing happens when I embed the content in NavigationView and I add a button to the bar. The button works only when closed, but not when open.

Anybody experienced anything similar? What is a good way to avoid this?

@gesabo
Copy link

gesabo commented Aug 22, 2021

This worked well in iOS 14 but I notice in iOS 15 when set up like the below, behavior is buggy..for example if you scroll to the bottom the bottom sheet is visible at the bottom of the screen - or if you show the bottom sheet then dismiss it also is still visible at the bottom fo the screen. Its like the min height of 0 isn't working 🤔


 var body: some View {
        ZStack {
            NavigationView {
                ScrollView {
                      //content
            } //end of ScrollView    
            } //end of Navigation View
            BottomSheetView(
                isOpen: self.$bottomSheetShown,
                maxHeight: getHeightForBottomSheetView()
            ) {
        
                    ExplanationView()
                    
                }
            }
        } //End of ZStack for Bottom Sheet View
    }

@Mithun-me
Copy link

Mithun-me commented Aug 24, 2021

How to dismiss a presented view in bottommodal?

@dldnh
Copy link

dldnh commented Nov 12, 2021

hi, thanks for sharing this! can I use this in my project? if so, what is the license?

@tylerlantern
Copy link

tylerlantern commented Jan 9, 2022

I modified a little so i can have the same behavior as apple map does.
Thank you for your solution anyway

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
}


public enum BottomSheetDisplayType {
  case fullScreen
  case halfScreen
  case none
}

struct BottomSheetAdvanceView<Content: View>: View {
    @Binding var displayType: BottomSheetDisplayType

    let maxHeight: CGFloat
    let minHeight: CGFloat
    let content: Content

    @GestureState private var translation: CGFloat = 0
  //MARK:- Offset from top edge
    private var offset: CGFloat {
      switch displayType {
      case .fullScreen :
        return 0
      case .halfScreen :
        return maxHeight * 0.40
      case .none :
        return 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(displayType: Binding<BottomSheetDisplayType>, maxHeight: CGFloat, @ViewBuilder content: () -> Content) {
        self.minHeight = 70
        self.maxHeight = maxHeight
        self.content = content()
        self._displayType = displayType
    }

    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 snapDistanceFullScreen = self.maxHeight * 0.35
                  let snapDistanceHalfScreen =  self.maxHeight * 0.85
                  if value.location.y <= snapDistanceFullScreen {
                    self.displayType = .fullScreen
                  } else if value.location.y > snapDistanceFullScreen  &&  value.location.y <= snapDistanceHalfScreen{
                    self.displayType = .halfScreen
                  }else {
                    self.displayType = .none
                  }
                }
            )
        }
    }
}

@saroar
Copy link

saroar commented Jan 17, 2022

its no make sense to use when we use TabBar becz is not hide tabbar :(
Снимок экрана 2022-01-17 в 15 48 29

@X901
Copy link

X901 commented Jan 17, 2022

@saroar you can use it above TabBar
Install SwiftUIX library then use it iniside
.windowOverlay() , it will show above TabBar !

@saroar
Copy link

saroar commented Jan 17, 2022

@X901 dont get your point.

@X901
Copy link

X901 commented Jan 17, 2022

@saroar
Download SwiftUIX

use it like this

 .windowOverlay(isKeyAndVisible: self.$optionsShown, {
            GeometryReader { _ in
                
                BottomSheetView(
                    isOpen: $optionsShown
                ) {
                    if optionsShown {
                        OptionsView()
                    }
                    
                }
                .edgesIgnoringSafeArea(.all)
            }

        })

you view will appear above TabBar
try it =)

the reason is when using windowOverlay anything inside it will show above all window
that why the Tabbar will move behind

@jay-cohen
Copy link

jay-cohen commented Feb 3, 2022

@mecid It may be a bug within SwiftUI .transition but applying a .move(.bottom) transition to either the BottomSheetView or its parent results in an inconsistent animation. 7-7 out of 10 times it works. The first time this runs it just pops into view and the animation shows only on second time. If you use the drag handle the animation is cancelled and the next time it pops open.

This is fairly old so is there a new way of implementing a bottom sheet?

@X901
Copy link

X901 commented Feb 3, 2022

@jay-cohen There is another way, but it only works in iOS 15
the other issue is only middle or large you cannot make it work with small size

it's exactly as what apple use in the Maps app

https://youtu.be/rQKT7tn4uag

@jay-cohen
Copy link

jay-cohen commented Feb 3, 2022

@X901 - Thanks for the suggestion.

@mecid
Copy link
Author

mecid commented Mar 4, 2022

@jay-cohen you can solve it by changing animation line with .animation(.interactiveSpring(), value: translation)

@edihasaj
Copy link

edihasaj commented Mar 8, 2022

@saroar Download SwiftUIX

use it like this

 .windowOverlay(isKeyAndVisible: self.$optionsShown, {
            GeometryReader { _ in
                
                BottomSheetView(
                    isOpen: $optionsShown
                ) {
                    if optionsShown {
                        OptionsView()
                    }
                    
                }
                .edgesIgnoringSafeArea(.all)
            }

        })

you view will appear above TabBar try it =)

the reason is when using windowOverlay anything inside it will show above all window that why the Tabbar will move behind

Thanks man.

And many thanks for the gist...

@saroar
Copy link

saroar commented Mar 9, 2022

@edihasaj i end up with my own and its super easy thanks

@edihasaj
Copy link

edihasaj commented Mar 9, 2022

@saroar is there any way I can make the under view as black/gray shadow? and also to when I click outside of the modal to dismiss the view by setting isOpen false?

Thank you

@saroar
Copy link

saroar commented Mar 9, 2022

here what you can do

.background(.grey)
.opacity(0.3)

https://www.hackingwithswift.com/quick-start/swiftui/how-to-layer-views-on-top-of-each-other-using-zstack
hope it can help u

@X901
Copy link

X901 commented Mar 9, 2022

@saroar Download SwiftUIX
use it like this

 .windowOverlay(isKeyAndVisible: self.$optionsShown, {
            GeometryReader { _ in
                
                BottomSheetView(
                    isOpen: $optionsShown
                ) {
                    if optionsShown {
                        OptionsView()
                    }
                    
                }
                .edgesIgnoringSafeArea(.all)
            }

        })

you view will appear above TabBar try it =)
the reason is when using windowOverlay anything inside it will show above all window that why the Tabbar will move behind

Thanks man.

And many thanks for the gist...

No Problem

But there is one issue, Animation won’t work when opening it, it will appear without animation (From bottom to top)

I cannot find any way to make it work with animation

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