30

I am trying to implement a button that presents another scene with a "Slide from Botton" animation.

PresentationButton looked like a good candidate, so I gave it a try:

import SwiftUI

struct ContentView : View {
    var body: some View {
        NavigationView {
            PresentationButton(destination: Green().frame(width: 1000.0)) {
                Text("Click")

                }.navigationBarTitle(Text("Navigation"))
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .previewDevice("iPhone X")
                .colorScheme(.dark)

            ContentView()
                .colorScheme(.dark)
                .previewDevice("iPad Pro (12.9-inch) (3rd generation)"

            )

        }

    }
}
#endif

And here is the result: enter image description here

I want the green view to cover the whole screen, and also the modal to be not "draggable to close".

Is it possible to add modifier to PresentationButton to make it full screen, and not draggable?

I have also tried a Navigation Button, but: - It doesn't "slide from bottom" - It creates a "back button" on detail view, which I don't want

thanks!

Mane Manero
  • 3,086
  • 5
  • 25
  • 47

6 Answers6

24

Unfortunately, as of Beta 2 Beta 3, this is not possible in pure SwiftUI. You can see that Modal has no parameters for anything like UIModalPresentationStyle.fullScreen. Likewise for PresentationButton.

I suggest filing a radar.

The nearest you can currently do is something like:

    @State var showModal: Bool = false
    var body: some View {
        NavigationView {
            Button(action: {
                self.showModal = true
            }) {
                Text("Tap me!")
            }
        }
        .navigationBarTitle(Text("Navigation!"))
        .overlay(self.showModal ? Color.green : nil)
    }

Of course, from there you can add whatever transition you like in the overlay.

arsenius
  • 12,090
  • 7
  • 58
  • 76
20

Although my other answer is currently correct, people probably want to be able to do this now. We can use the Environment to pass a view controller to children. Gist here

struct ViewControllerHolder {
    weak var value: UIViewController?
}


struct ViewControllerKey: EnvironmentKey {
    static var defaultValue: ViewControllerHolder { return ViewControllerHolder(value: UIApplication.shared.windows.first?.rootViewController ) }
}

extension EnvironmentValues {
    var viewController: UIViewControllerHolder {
        get { return self[ViewControllerKey.self] }
        set { self[ViewControllerKey.self] = newValue }
    }
}

Add an extension to UIViewController

extension UIViewController {
    func present<Content: View>(style: UIModalPresentationStyle = .automatic, @ViewBuilder builder: () -> Content) {
        // Must instantiate HostingController with some sort of view...
        let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
        toPresent.modalPresentationStyle = style
        // ... but then we can reset rootView to include the environment
        toPresent.rootView = AnyView(
            builder()
                .environment(\.viewController, ViewControllerHolder(value: toPresent))
        )
        self.present(toPresent, animated: true, completion: nil)
    }
}

And whenever we need it, use it:

struct MyView: View {

    @Environment(\.viewController) private var viewControllerHolder: ViewControllerHolder
    private var viewController: UIViewController? {
        self.viewControllerHolder.value
    }

    var body: some View {
        Button(action: {
           self.viewController?.present(style: .fullScreen) {
              MyView()
           }
        }) {
           Text("Present me!")
        }
    }
}

[EDIT] Although it would be preferable to do something like @Environment(\.viewController) var viewController: UIViewController? this leads to a retain cycle. Therefore, you need to use the holder.

arsenius
  • 12,090
  • 7
  • 58
  • 76
  • Is there an error in this code? Because I've tried using your gist on Beta 6, and at the line `self.viewControllerHolder.value` inside `MyView` the compiler gives the error `Ambiguous reference to member 'value(forKey:)'`, so I've tried using `self.viewControllerHolder?.value(forKey: "") as? UIViewController` and it crashes with error `valueForKey:]: this class is not key value coding-compliant for the key .'` – riciloma Aug 28 '19 at 13:26
  • 1
    Figured it out: just remove `.value` from `self.viewControllerHolder.value`. In Beta 7 however, this translates to a standard `.sheet` modal, so it's not really full screen – riciloma Sep 03 '19 at 15:47
  • 2
    I suggest to add `toPresent.modalPresentationStyle = style` inside the `present` function of the `extension` to make the newly presented screen actually full screen – riciloma Sep 03 '19 at 16:04
  • To dismiss the `ViewController` I pass it to my `View` and then call `self.vc?.dismiss(animated: true, completion: {})` but I have the suspicion that this still keeps something in memory because when I call this again and then trigger an update on a `Publisher` and catch it via `onReceive()` it gets called as often as I've opened and dismissed the `ViewController` any thoughts? – krjw Oct 17 '19 at 17:01
  • @arsenius Any update on this code for the month of December 2019? I'm getting a "Cannot convert value of type 'Environment' to specified type 'UIViewController?'" for `@Environment(\.viewController) private var viewControllerHolder: UIViewController?` – Steven Schafer Dec 04 '19 at 21:12
  • @StevenSchafer That should have been `... var viewControllerHolder: ViewControllerHolder`. I've fixed my post. – arsenius Dec 04 '19 at 23:43
  • @arsenius Hello there, I used this solution and encountered a the following error. Which only appears on app's first launch. I think has something to do with the order view are presented. But not sure, may I ask if you encountered similar problem or know why it happened? Many thanks! "Warning: Attempt to present <_TtGC7SwiftUI19UIHostingControllerVS_7AnyView_: 0x7fa403ca0dc0> on whose view is not in the window hierarchy!" – Legolas Wang Feb 20 '20 at 21:18
12

Xcode 12.0 - SwiftUI 2 - iOS 14

Now possible. Use fullScreenCover() modifier.

var body: some View {
    Button("Present!") {
        self.isPresented.toggle()
    }
    .fullScreenCover(isPresented: $isPresented, content: FullScreenModalView.init)
}

Hacking With Swift

pawello2222
  • 46,897
  • 22
  • 145
  • 209
Eric
  • 197
  • 3
  • 12
3

This version fixes the compile error present in XCode 11.1 as well as ensures that controller is presented in the style that is passed in.

import SwiftUI

struct ViewControllerHolder {
    weak var value: UIViewController?
}

struct ViewControllerKey: EnvironmentKey {
    static var defaultValue: ViewControllerHolder {
        return ViewControllerHolder(value: UIApplication.shared.windows.first?.rootViewController)

    }
}

extension EnvironmentValues {
    var viewController: UIViewController? {
        get { return self[ViewControllerKey.self].value }
        set { self[ViewControllerKey.self].value = newValue }
    }
}

extension UIViewController {
    func present<Content: View>(style: UIModalPresentationStyle = .automatic, @ViewBuilder builder: () -> Content) {
        let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
        toPresent.modalPresentationStyle = style
        toPresent.rootView = AnyView(
            builder()
                .environment(\.viewController, toPresent)
        )
        self.present(toPresent, animated: true, completion: nil)
    }
}

To use this version, the code is unchanged from the previous version.

struct MyView: View {

    @Environment(\.viewController) private var viewControllerHolder: UIViewController?
    private var viewController: UIViewController? {
        self.viewControllerHolder.value
    }

    var body: some View {
        Button(action: {
           self.viewController?.present(style: .fullScreen) {
              MyView()
           }
        }) {
           Text("Present me!")
        }
    }
}
Gene Z. Ragan
  • 2,643
  • 2
  • 31
  • 41
  • I tried this code but it doesn't work. Initially you get an error `Value of optional type 'UIViewController?' must be unwrapped to refer to member 'value' of wrapped base type 'UIViewController'` but then force-unwrapping gives another error: `Ambiguous reference to member 'value(forKey:)'` - so you need to change `self.viewControllerHolder.value` to `self.viewControllerHolder!` – codewithfeeling Feb 03 '20 at 15:55
3

My solution for this (which you can easily extend to allow other params on the presented sheets to be tweaked) is to just subclass UIHostingController

//HSHostingController.swift

import Foundation
import SwiftUI

class HSHostingControllerParams {
    static var nextModalPresentationStyle:UIModalPresentationStyle?
}

class HSHostingController<Content> : UIHostingController<Content> where Content : View {

    override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {

        if let nextStyle = HSHostingControllerParams.nextModalPresentationStyle {
            viewControllerToPresent.modalPresentationStyle = nextStyle
            HSHostingControllerParams.nextModalPresentationStyle = nil
        }

        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }

}

use HSHostingController instead of UIHostingController in your scene delegate like so:

    // Use a HSHostingController as window root view controller.
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)

        //This is the only change from the standard boilerplate
        window.rootViewController = HSHostingController(rootView: contentView)

        self.window = window
        window.makeKeyAndVisible()
    }

then just tell the HSHostingControllerParams class what presentation style you want before triggering a sheet

        .navigationBarItems(trailing:
            HStack {
                Button("About") {
                    HSHostingControllerParams.nextModalPresentationStyle = .fullScreen
                    self.showMenuSheet.toggle()
                }
            }
        )

Passing the params via the class singleton feels a little 'dirty', but in practice - you would have to create a pretty obscure scenario for this not to work as expected.

You could mess around with environment variables and the like (as other answers have done) - but to me, the added complication isn't worth the purity.

update: see this gist for extended solution with additional capabilities

Confused Vorlon
  • 9,659
  • 3
  • 46
  • 49
2

So I was struggling with that and I didn't like the overlay feature nor the ViewController wrapped version since it gave me some memory bug and I am very new to iOS and only know SwiftUI and no UIKit.

I developed credits the following with just SwiftUI which is probably what an overlay does but for my purposes it is much more flexible:

struct FullscreenModalView<Presenting, Content>: View where Presenting: View, Content: View {

    @Binding var isShowing: Bool
    let parent: () -> Presenting
    let content: () -> Content

    @inlinable public init(isShowing: Binding<Bool>, parent: @escaping () -> Presenting, @ViewBuilder content: @escaping () -> Content) {
        self._isShowing = isShowing
        self.parent = parent
        self.content = content
    }

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                self.parent().zIndex(0)
                if self.$isShowing.wrappedValue {
                    self.content()
                    .background(Color.primary.colorInvert())
                    .edgesIgnoringSafeArea(.all)
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .transition(.move(edge: .bottom))
                    .zIndex(1)

                }
            }
        }
    }
}

Adding an extension to View:

extension View {

    func modal<Content>(isShowing: Binding<Bool>, @ViewBuilder content: @escaping () -> Content) -> some View where Content: View {
        FullscreenModalView(isShowing: isShowing, parent: { self }, content: content)
    }

}

Usage: Use a custom view and pass the showModal variable as a Binding<Bool> to dismiss the modal from the view itself.

struct ContentView : View {
    @State private var showModal: Bool = false
    var body: some View {
        ZStack {
            Button(action: {
                withAnimation {
                    self.showModal.toggle()
                }
            }, label: {
                HStack{
                   Image(systemName: "eye.fill")
                    Text("Calibrate")
                }
               .frame(width: 220, height: 120)
            })
        }
        .modal(isShowing: self.$showModal, content: {
            Text("Hallo")
        })
    }
}

I hope this helps!

Greetings krjw

krjw
  • 4,070
  • 1
  • 24
  • 49
  • 2
    It has limitations you must place it on top view, not on views nested in view hierarchies so it works differently then sheet – Michał Ziobro Dec 03 '19 at 14:51
  • Do you mean this or do you mean `.sheet`? – krjw Dec 03 '19 at 17:10
  • This if you attach this modifier to button nested in view nested in andother view then it is centered in center of this button or subview. Maybe it be possible to somehow set frame, position explicitly based on UIScreen size – Michał Ziobro Dec 04 '19 at 08:06
  • I think calculating difference between screen center and parent center and setting it as offset could help – Michał Ziobro Dec 04 '19 at 08:08