92

I'm trying to recreate a Modal just like Safari in iOS13 in SwiftUI:

Here's what it looks like:

enter image description here

Does anyone know if this is possible in SwiftUI? I want to show a small half modal, with the option to drag to fullscreen, just like the sharing sheet.

Any advice is much appreciated!

Jonas Deichelmann
  • 3,513
  • 1
  • 30
  • 45
ryannn
  • 1,329
  • 1
  • 9
  • 21
  • Wouldn't it be just [`overlay`](https://developer.apple.com/documentation/swiftui/securefield/3289382-overlay)? – user28434'mstep Jun 21 '19 at 09:53
  • 1
    I'm not too sure, I thought it might be an option on a Modal or popover, but the docs are quite sparse at the moment – ryannn Jun 21 '19 at 10:03
  • 1
    I found modals in SwiftUI to fall too short on options. For example, I could not find a way to select its presentation style to "FormSheet". Something very basic that has been around since iOS 3.2!. I had to go with the UIHostingController trick. – kontiki Jun 21 '19 at 15:31
  • 6
    Made this in pure SwiftUI. Enjoy! https://github.com/cyrilzakka/SwiftUIModal. Enables fullscreen and half modal capabilities. – cyril Aug 01 '19 at 06:03
  • @cyril do you give permission for anyone to use your code? – Kyle Beard May 01 '20 at 17:37
  • @KyleBeard yes it's open source on Github – cyril May 02 '20 at 19:45
  • 3
    @ryannn It looks like half sheet is finally supported in iOS 16 - for details see [this answer](https://stackoverflow.com/a/72523916/8697793). – pawello2222 Jun 06 '22 at 22:08

14 Answers14

35

In Swift 5.5 iOS 15+ and Mac Catalyst 15+ there is a

There is a new solution with adaptiveSheetPresentationController

https://developer.apple.com/documentation/uikit/uipopoverpresentationcontroller/3810055-adaptivesheetpresentationcontrol?changes=__4

@available(iOS 15.0, *)
struct CustomSheetParentView: View {
    @State private var isPresented = false
    
    var body: some View {
        VStack{
            Button("present sheet", action: {
                isPresented.toggle()
            }).adaptiveSheet(isPresented: $isPresented, detents: [.medium()], smallestUndimmedDetentIdentifier: .large){
                Rectangle()
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                    .foregroundColor(.clear)
                    .border(Color.blue, width: 3)
                    .overlay(Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: .infinity)
                                .onTapGesture {
                        isPresented.toggle()
                    }
                    )
            }
            
        }
    }
}
@available(iOS 15.0, *)
struct AdaptiveSheet<T: View>: ViewModifier {
    let sheetContent: T
    @Binding var isPresented: Bool
    let detents : [UISheetPresentationController.Detent]
    let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
    let prefersScrollingExpandsWhenScrolledToEdge: Bool
    let prefersEdgeAttachedInCompactHeight: Bool
    
    init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> T) {
        self.sheetContent = content()
        self.detents = detents
        self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
        self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
        self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
        self._isPresented = isPresented
    }
    func body(content: Content) -> some View {
        ZStack{
            content
            CustomSheet_UI(isPresented: $isPresented, detents: detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {sheetContent}).frame(width: 0, height: 0)
        }
    }
}
@available(iOS 15.0, *)
extension View {
    func adaptiveSheet<T: View>(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> T)-> some View {
        modifier(AdaptiveSheet(isPresented: isPresented, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: content))
    }
}

@available(iOS 15.0, *)
struct CustomSheet_UI<Content: View>: UIViewControllerRepresentable {
    
    let content: Content
    @Binding var isPresented: Bool
    let detents : [UISheetPresentationController.Detent]
    let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
    let prefersScrollingExpandsWhenScrolledToEdge: Bool
    let prefersEdgeAttachedInCompactHeight: Bool
    
    init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.detents = detents
        self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
        self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
        self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
        self._isPresented = isPresented
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    func makeUIViewController(context: Context) -> CustomSheetViewController<Content> {
        let vc = CustomSheetViewController(coordinator: context.coordinator, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge:  prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {content})
        return vc
    }
    
    func updateUIViewController(_ uiViewController: CustomSheetViewController<Content>, context: Context) {
        if isPresented{
            uiViewController.presentModalView()
        }else{
            uiViewController.dismissModalView()
        }
    }
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        var parent: CustomSheet_UI
        init(_ parent: CustomSheet_UI) {
            self.parent = parent
        }
        //Adjust the variable when the user dismisses with a swipe
        func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
            if parent.isPresented{
                parent.isPresented = false
            }
            
        }
        
    }
}

@available(iOS 15.0, *)
class CustomSheetViewController<Content: View>: UIViewController {
    let content: Content
    let coordinator: CustomSheet_UI<Content>.Coordinator
    let detents : [UISheetPresentationController.Detent]
    let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
    let prefersScrollingExpandsWhenScrolledToEdge: Bool
    let prefersEdgeAttachedInCompactHeight: Bool
    private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
    init(coordinator: CustomSheet_UI<Content>.Coordinator, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.coordinator = coordinator
        self.detents = detents
        self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
        self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
        self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
        super.init(nibName: nil, bundle: .main)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func dismissModalView(){
        dismiss(animated: true, completion: nil)
    }
    func presentModalView(){
        
        let hostingController = UIHostingController(rootView: content)
        
        hostingController.modalPresentationStyle = .popover
        hostingController.presentationController?.delegate = coordinator as UIAdaptivePresentationControllerDelegate
        hostingController.modalTransitionStyle = .coverVertical
        if let hostPopover = hostingController.popoverPresentationController {
            hostPopover.sourceView = super.view
            let sheet = hostPopover.adaptiveSheetPresentationController
            //As of 13 Beta 4 if .medium() is the only detent in landscape error occurs
            sheet.detents = (isLandscape ? [.large()] : detents)
            sheet.largestUndimmedDetentIdentifier =
            smallestUndimmedDetentIdentifier
            sheet.prefersScrollingExpandsWhenScrolledToEdge =
            prefersScrollingExpandsWhenScrolledToEdge
            sheet.prefersEdgeAttachedInCompactHeight =
            prefersEdgeAttachedInCompactHeight
            sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
            
        }
        if presentedViewController == nil{
            present(hostingController, animated: true, completion: nil)
        }
    }
    /// To compensate for orientation as of 13 Beta 4 only [.large()] works for landscape
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        if UIDevice.current.orientation.isLandscape {
            isLandscape = true
            self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.large()]
        } else {
            isLandscape = false
            self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = detents
        }
    }
}

@available(iOS 15.0, *)
struct CustomSheetView_Previews: PreviewProvider {
    static var previews: some View {
        CustomSheetParentView()
    }
}

iOS 16 Beta

In iOS 16 Beta Apple provides a pure SwiftUI solution for a Half-Modal.

    .sheet(isPresented: $showSettings) {
        SettingsView()
            .presentationDetents:(
                [.medium, .large],
                selection: $settingsDetent
             )
    }

You can also add custom detents and specify the percentages

static func custom<D>(D.Type) -> PresentationDetent
//A custom detent with a calculated height.
static func fraction(CGFloat) -> PresentationDetent
//A custom detent with the specified fractional height.
static func height(CGFloat) -> PresentationDetent
//A custom detent with the specified height.

Example:

extension PresentationDetent {
    static let bar = Self.fraction(0.2)
}

.sheet(isPresented: $showSettings) {
    SettingsView()
        .presentationDetents:([.bar])
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • This is crashing in landscape with the error: 'At least one detent must be active.' on iOS 15 beta 4. Has anyone got a solution? – Sverrisson Aug 09 '21 at 13:42
  • The bug is when CustomSheet_UI(isPresented: $isPresented, detents: [.medium()], ... If I give it detents of [.medium(), .large()] then it doesn't crash. This is not with your code but with the framework, sorry for the wrong call before. – Sverrisson Aug 10 '21 at 09:58
  • 1
    @Sverrisson NP. I like the feedback. I added a workaround. For now it will default to `.large()` if in landscape. I'll submit a bug. It might help if others submit one too – lorem ipsum Aug 10 '21 at 13:18
  • This is nice but Is there any similar solution that'd work for below iOS 15 ? – C0D3 Sep 17 '21 at 19:40
  • Not as dynamic. I’ve seen old UIKit posts were people set the frame manually in SO. The new code is the stuff related to adaptiveSheetPresentationController the rest should work well in iOS 13 and 14. Also, you can check the version and just use a sheet for 13 and 14 and of course there all all the other solutions on here. – lorem ipsum Sep 17 '21 at 19:53
  • How can I set the size programatically using this approach? I want to change `selectedDetentIdentifier` of the presented sheet, changing its size by clicking a button. I'm not familiar at all with UIKit, so I don't know where to start.. – Bjørn Olav Jalborg Nov 25 '21 at 15:52
  • 1
    @BjørnOlavJalborg you would have to do something like [this](https://stackoverflow.com/questions/69942854/how-to-pass-textfield-value-to-view-controller-through-button-click-in-swift-ui/69945806#69945806) and in the custom method you would change the variable similarly to how it is changed in `viewWillTransition` – lorem ipsum Nov 25 '21 at 18:28
  • This code works great, except for two things. 1. I can't make the content dynamic and react to state changes. `@State var content = "a" switch content { case "a": Text("im a") case "b": Text("im b")}` <-- Will always show "im a", no matter what I do to the content variable. 2. I can't have more than one adaptiveSheet in one View at a time. I would like to have button A open a sheet with content A, and button B to open a sheet with content B. But they're interlinked. Any idea if that's possible to solve? – Bjørn Olav Jalborg Feb 04 '22 at 10:52
  • 1
    @BjørnOlavJalborg do you get the same behavior with sheet? Just trying to replicate on my end. Apple uses the sheet with item in the unit to overcome it if the issue is what I think it is. Let me know, if so I think I have a solution. – lorem ipsum Feb 04 '22 at 13:54
  • Don't get the same behavior with sheet. It works as expected. – Bjørn Olav Jalborg Feb 04 '22 at 14:30
  • 1
    @BjørnOlavJalborg try putting the `switch` in a subview with a `@Binding var content: String` variable. The `@Binding` will trigger the change. – lorem ipsum Feb 04 '22 at 15:13
  • 3
    you dropped this. It works perfectly. Thank you so much. – Bjørn Olav Jalborg Feb 05 '22 at 11:56
  • Awesome answer I noticed you used `smallestUndimmedDetentIdentifier` when the documentation uses `largestUndimmedDetentIdentifier`. Was that a mistake @loremipsum? – Patrick May 06 '22 at 18:40
  • @Patrick not a mistake, it is a change. – lorem ipsum May 06 '22 at 19:47
  • I discovered a bug when utilising this with a document app. A document app presents a new UIViewController when a documented is created. Using this sheet and swiping down dismisses the document as well. – Patrick May 21 '22 at 16:01
  • 1
    @Patrick since `adaptiveSheetPresentationController` was introduced last June hopefully apple with have a pure SwiftUI way this June. We will find out soon. I don't really make document apps, there are a few things I would change if I were to remake this particular version of this sheet. – lorem ipsum May 21 '22 at 16:58
  • 1
    @Patrick Apple added the detents to the new sheet in iOS 16, see above – lorem ipsum Jun 06 '22 at 23:11
  • If anyone wants round corners just use the add this to the code sheet.preferredCornerRadius = 50 – Tiago Mendes Jul 21 '23 at 09:05
24

I've written a Swift Package that includes a custom modifier that allows you to use the half modal sheet.

Here is the link: https://github.com/AndreaMiotto/PartialSheet

Feel free to use it or to contribute

enter image description here

Andrea Miotto
  • 7,084
  • 8
  • 45
  • 70
22

iOS 16+

It looks like half sheet is finally supported in iOS 16.

To manage the size of sheet we can use PresentationDetent and specifically presentationDetents(_:selection:)

Here's an example from the documentation:

struct ContentView: View {
    @State private var showSettings = false
    @State private var settingsDetent = PresentationDetent.medium

    var body: some View {
        Button("View Settings") {
            showSettings = true
        }
        .sheet(isPresented: $showSettings) {
            SettingsView()
                .presentationDetents(
                    [.medium, .large],
                    selection: $settingsDetent
                 )
        }
    }
}

Note that if you provide more that one detent, people can drag the sheet to resize it.

Here are possible values for PresentationDetent:

  • large
  • medium
  • fraction(CGFloat)
  • height(CGFloat)
  • custom<D>(D.Type)
Josh Brown
  • 52,385
  • 10
  • 54
  • 80
pawello2222
  • 46,897
  • 22
  • 145
  • 209
16

You can make your own and place it inside of a zstack: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks

struct SlideOverCard<Content: View> : View {
    @GestureState private var dragState = DragState.inactive
    @State var position = CardPosition.top

    var content: () -> Content
    var body: some View {
        let drag = DragGesture()
            .updating($dragState) { drag, state, transaction in
                state = .dragging(translation: drag.translation)
            }
            .onEnded(onDragEnded)

        return Group {
            Handle()
            self.content()
        }
        .frame(height: UIScreen.main.bounds.height)
        .background(Color.white)
        .cornerRadius(10.0)
        .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
        .offset(y: self.position.rawValue + self.dragState.translation.height)
        .animation(self.dragState.isDragging ? nil : .spring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
        .gesture(drag)
    }

    private func onDragEnded(drag: DragGesture.Value) {
        let verticalDirection = drag.predictedEndLocation.y - drag.location.y
        let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
        let positionAbove: CardPosition
        let positionBelow: CardPosition
        let closestPosition: CardPosition

        if cardTopEdgeLocation <= CardPosition.middle.rawValue {
            positionAbove = .top
            positionBelow = .middle
        } else {
            positionAbove = .middle
            positionBelow = .bottom
        }

        if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
            closestPosition = positionAbove
        } else {
            closestPosition = positionBelow
        }

        if verticalDirection > 0 {
            self.position = positionBelow
        } else if verticalDirection < 0 {
            self.position = positionAbove
        } else {
            self.position = closestPosition
        }
    }
}

enum CardPosition: CGFloat {
    case top = 100
    case middle = 500
    case bottom = 850
}

enum DragState {
    case inactive
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }

    var isDragging: Bool {
        switch self {
        case .inactive:
            return false
        case .dragging:
            return true
        }
    }
}
Andre Carrera
  • 2,606
  • 2
  • 12
  • 15
9

Here's my naive bottom sheet which scales to its content. Without dragging but it should be relatively easy to add if needed :)

struct BottomSheet<SheetContent: View>: ViewModifier {
    @Binding var isPresented: Bool
    let sheetContent: () -> SheetContent

    func body(content: Content) -> some View {
        ZStack {
            content
        
            if isPresented {
                VStack {
                    Spacer()
                
                    VStack {
                        HStack {
                            Spacer()
                            Button(action: {
                                withAnimation(.easeInOut) {
                                    self.isPresented = false
                                }
                            }) {
                                Text("done")
                                    .padding(.top, 5)
                            }
                        }
                    
                        sheetContent()
                    }
                    .padding()
                }
                .zIndex(.infinity)
                .transition(.move(edge: .bottom))
                .edgesIgnoringSafeArea(.bottom)
            }
        }
    }
}

extension View {
    func customBottomSheet<SheetContent: View>(
        isPresented: Binding<Bool>,
        sheetContent: @escaping () -> SheetContent
    ) -> some View {
        self.modifier(BottomSheet(isPresented: isPresented, sheetContent: sheetContent))
    }
}

and use like below:

.customBottomSheet(isPresented: $isPickerPresented) {
                DatePicker(
                    "time",
                    selection: self.$time,
                    displayedComponents: .hourAndMinute
                )
                .labelsHidden()
        }
Szymon W
  • 489
  • 8
  • 15
7

As of Beta 2 Beta 3 you can't present a modal View as .fullScreen. It presents as .automatic -> .pageSheet. Even once that's fixed, though, I highly doubt they will give you the drag capability there for free. It would be included in the docs already.

You can use this answer to present full screen for now. Gist here.

Then, after presentation, this is a quick and dirty example of how you can recreate that interaction.

    @State var drag: CGFloat = 0.0

    var body: some View {
        ZStack(alignment: .bottom) {
            Spacer() // Use the full space
            Color.red
                .frame(maxHeight: 300 + self.drag) // Whatever minimum height you want, plus the drag offset
                .gesture(
                    DragGesture(coordinateSpace: .global) // if you use .local the frame will jump around
                        .onChanged({ (value) in
                            self.drag = max(0, -value.translation.height)
                        })
                )
        }
    }
arsenius
  • 12,090
  • 7
  • 58
  • 76
7

I have written a SwiftUI package which includes custom iOS 13 like half modal and its buttons.

GitHub repo: https://github.com/ViktorMaric/HalfModal

Preview of HalfModal

Viktor Maric
  • 443
  • 1
  • 4
  • 10
5

I think almost every iOS developer who writes anything in SwiftUI must come up against this. I certainly did, but I thought that most of the answers here were either too complex or didn't really provide what I wanted.

I've written a very simple partial sheet which is on GitHub, available as a Swift package - HalfASheet

It probably doesn't have the bells & whistles of some of the other solutions, but it does what it needs to do. Plus, writing your own is always good for understanding what's going on.

Note - A couple of things - First of all, this is very much a work-in-progress, please feel free to improve it, etc. Secondly, I've deliberately not done a .podspec as if you're developing for SwiftUI you're on iOS 13 minimum, and the Swift Packages are so much nicer in my opinion...

SomaMan
  • 4,127
  • 1
  • 34
  • 45
  • How do i set size when using the declarative one?? – Farhandika May 09 '21 at 15:55
  • @Farhandika so this is very much a work-in-progress, and it almost certainly needs further work to make it into a robust, real-world solution. Feel free to poke around the code & see if you can improve it – SomaMan May 12 '21 at 15:05
  • > in my opinion doesn't negate the fact that you're leaving out a _very significant_ portion of current iOS projects that have been around for years. at least be honest - you didn't want to put in the effort to create the podspec. period. not because one is better, you just didn't want to do it. – tsalaroth Aug 31 '23 at 14:21
3

>>Update from the WWDC22
You can create half modals or small modals just using this tutorial at the minute 02:40 . It was one of the impressive way to resize the Modal without using any complex code. Just caring about the presentation.

Link video : enter link description here

Let's get from the usage :

.sheet(isPresented : yourbooleanvalue) {
  //place some content inside
  Text("test")
    .presentationDetents([.medium,.large])
}

in this way you set a Modal that can be medium at the start and be dragged up to be large. But you can also use, .small attribute inside of this array of dimensions. I think it was the shortest path and the most use friendly. Now this method saved me life from thousand of lines of code.

Davencode
  • 75
  • 6
2

Andre Carrera's answer is great and feel free to use this guide he provided: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks

I have modified the SlideOverCard structure so it uses actual device height to measure where the card is supposed to stop (you can play with bounds.height to adjust for your needs):

struct SlideOverCard<Content: View>: View {

    var bounds = UIScreen.main.bounds
    @GestureState private var dragState = DragState.inactive
    @State var position = UIScreen.main.bounds.height/2

    var content: () -> Content
    var body: some View {
        let drag = DragGesture()
            .updating($dragState) { drag, state, transaction in
                state = .dragging(translation: drag.translation)
            }
            .onEnded(onDragEnded)

        return Group {
            Handle()
            self.content()
        }
        .frame(height: UIScreen.main.bounds.height)
        .background(Color.white)
        .cornerRadius(10.0)
        .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
        .offset(y: self.position + self.dragState.translation.height)
        .animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
        .gesture(drag)
    }

    private func onDragEnded(drag: DragGesture.Value) {
        let verticalDirection = drag.predictedEndLocation.y - drag.location.y
        let cardTopEdgeLocation = self.position + drag.translation.height
        let positionAbove: CGFloat
        let positionBelow: CGFloat
        let closestPosition: CGFloat

        if cardTopEdgeLocation <= bounds.height/2 {
            positionAbove = bounds.height/7
            positionBelow = bounds.height/2
        } else {
            positionAbove = bounds.height/2
            positionBelow = bounds.height - (bounds.height/9)
        }

        if (cardTopEdgeLocation - positionAbove) < (positionBelow - cardTopEdgeLocation) {
            closestPosition = positionAbove
        } else {
            closestPosition = positionBelow
        }

        if verticalDirection > 0 {
            self.position = positionBelow
        } else if verticalDirection < 0 {
            self.position = positionAbove
        } else {
            self.position = closestPosition
        }
    }
}

enum DragState {
    case inactive
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }

    var isDragging: Bool {
        switch self {
        case .inactive:
            return false
        case .dragging:
            return true
        }
    }
}
1

I was trying to do the same thing asked here, display the share sheet in a natively manner in SwiftUI without to have to implement / import a component. I've found this solution in https://jeevatamil.medium.com/how-to-create-share-sheet-uiactivityviewcontroller-in-swiftui-cef64b26f073

struct ShareSheetView: View {
    var body: some View {
        Button(action: actionSheet) {
            Image(systemName: "square.and.arrow.up")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 36, height: 36)
        }
    }
    
    func actionSheet() {
        guard let data = URL(string: "https://www.zoho.com") else { return }
        let av = UIActivityViewController(activityItems: [data], applicationActivities: nil)
        UIApplication.shared.windows.first?.rootViewController?.present(av, animated: true, completion: nil)
    }
}
Yensi Vega
  • 11
  • 1
1

Works by me:

var body: some View {
    ZStack {
        YOURTOPVIEW()
        VStack {
            Spacer()
                .frame(minWidth: .zero,
                       maxWidth: .infinity,
                       minHeight: .zero,
                       maxHeight: .infinity,
                       alignment: .top)
            YOURBOTTOMVIEW()
                .frame(minWidth: .zero,
                       maxWidth: .infinity,
                       minHeight: .zero,
                       maxHeight: .infinity,
                       alignment: .bottom)
        }

    }
}
pkamb
  • 33,281
  • 23
  • 160
  • 191
Illya Krit
  • 925
  • 1
  • 8
  • 9
0

In iOS 14, Swift 5, Xcode 12.5 at least, I was able to accomplish this fairly easily by simply wrapping the the UIActivityViewController in another view controller. It doesn't require inspecting the view hierarchy or using any 3rd party libraries. The only hackish part is asynchronously presenting the view controller, which might not even be necessary. Someone with more SwiftUI experience might be able to offer suggestions for improvement.

import Foundation
import SwiftUI
import UIKit

struct ActivityViewController: UIViewControllerRepresentable {
        
    @Binding var shareURL: URL?
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> some UIViewController {
        let containerViewController = UIViewController()
        
        return containerViewController

    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        guard let shareURL = shareURL, context.coordinator.presented == false else { return }
        
        context.coordinator.presented = true

        let activityViewController = UIActivityViewController(activityItems: [shareURL], applicationActivities: nil)
        activityViewController.completionWithItemsHandler = { activity, completed, returnedItems, activityError in
            self.shareURL = nil
            context.coordinator.presented = false

            if completed {
                // ...
            } else {
                // ...
            }
        }
        
        // Executing this asynchronously might not be necessary but some of my tests
        // failed because the view wasn't yet in the view hierarchy on the first pass of updateUIViewController
        //
        // There might be a better way to test for that condition in the guard statement and execute this
        // synchronously if we can be be sure updateUIViewController is invoked at least once after the view is added
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            uiViewController.present(activityViewController, animated: true)
        }
    }
    
    class Coordinator: NSObject {
        let parent: ActivityViewController
        
        var presented: Bool = false
        
        init(_ parent: ActivityViewController) {
            self.parent = parent
        }
    }
    
}
struct ContentView: View {
    
    @State var shareURL: URL? = nil
    
    var body: some View {
        ZStack {
            Button(action: { shareURL = URL(string: "https://apple.com") }) {
                Text("Share")
                    .foregroundColor(.white)
                    .padding()
            }
            .background(Color.blue)
            if shareURL != nil {
                ActivityViewController(shareURL: $shareURL)
            }
        }
        .frame(width: 375, height: 812)
    }
}
kball
  • 4,923
  • 3
  • 29
  • 31
0

For a more generic solution, I have come up with the following idea: https://github.com/mtzaquia/UIKitPresentationModifier

This is a generic modifier that allows you to use UIKit presentations within a SwiftUI view.

From there, the world is your oyster. The only drawback is that you may need to cascade custom environment values from the presenting view into the presented view.

myPresentingView
  .presentation(isPresented: $isPresented) {
    MyPresentedView()
  } controllerProvider: { content in
    let controller = UIHostingController(rootView: content)
    if #available(iOS 15, *) {
      if let sheet = controller.sheetPresentationController {
        sheet.preferredCornerRadius = 12
        sheet.prefersGrabberVisible = true
      }
    }
    
    return controller
  }
mtzaquia
  • 61
  • 3