Skip to content

Instantly share code, notes, and snippets.

@mecid
Last active April 17, 2024 20:10
Show Gist options
  • Save mecid/78eab34d05498d6c60ae0f162bfd81ee to your computer and use it in GitHub Desktop.
Save mecid/78eab34d05498d6c60ae0f162bfd81ee to your computer and use it in GitHub Desktop.
//
// 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)
}
}
@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

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

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

@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

@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

@dmikots
Copy link

dmikots commented Sep 15, 2022

image

I have this problem (

@ptsiogas
Copy link

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
                  }
                }
            )
        }
    }
}

Your version plays well but is it possible to support a "wrap content" height display type?

@gaohomway
Copy link

Can it support dragging to open and close the list?

GeometryReader { geometry in
            Color.green
            BottomSheetView(
                isOpen: self.$bottomSheetShown,
                maxHeight: geometry.size.height * 0.7
            ) {
                List {
                    Text("A List Item")
                    Text("A Second List Item")
                    Text("A Third List Item")
                }
            }
        }.edgesIgnoringSafeArea(.all)

@OliviaBeam
Copy link

@ptsiogas can your version support scroll views or lists inside it?

@gaohomway
Copy link

@tylerlantern How .animation(.interactiveSpring()) is changed to .animation(.interactiveSpring(), value: V) ?

@GMetaxakis
Copy link

GMetaxakis commented Feb 2, 2024

@tylerlantern How .animation(.interactiveSpring()) is changed to .animation(.interactiveSpring(), value: V) ?

if you use @ptsiogas solution with
.animation(.interactiveSpring(), value: displayType)
or if you use the original
.animation(.interactiveSpring(), value: isOpen)

(only almost a year late)

@GMetaxakis
Copy link

GMetaxakis commented Feb 2, 2024

@ptsiogas can your version support scroll views or lists inside it?

@OliviaBeam

for using the with scrolling list in our solution we change the body to be scrollable or not based on the displayType

if the displayType is for fullscreen we render a scrollview with the content, otherwise only the content.

(You will need also a way to revert the displayType back to non-full screen after inverted scroll on your scroll list)
(only almost a year late)

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