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