SwiftUI: Translucent background for fullScreenCover - ios

So technically I want to show a loading screen view. I'm using fullScreenCover.
struct ContentView: View {
#State private var isLoading = false
var body: some View {
VStack {
Text("Hello there")
Button("Start loading") {
isLoading.toggle()
}
.fullScreenCover(isPresented: $isLoading) {
ZStack{
Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)
VStack {
ProgressView()
Button("Stop loading") {
isLoading.toggle()
}
}
}
}
}
}
}
The problem is that I cannot make this loading screen translucent. sheet or popover behave the same way.

Here is a demo of possible way. Parameters of visual effect you can tune for your needs.
Tested with Xcode 12 / iOS 14.
// ... other code
.fullScreenCover(isPresented: $isLoading) {
ZStack{
Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)
VStack {
ProgressView()
Button("Stop loading") {
isLoading.toggle()
}
}
}
.background(BackgroundBlurView())
}
}
}
}
struct BackgroundBlurView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .light))
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

Building on top of f3dm76 answer:
I changed it so content behind would not flicker (that happened to me with lazy images loading behind fullScreenCover). Also I wanted to use custom transitions for full screen content (or in some cases no animation at all), so I removed default animations with this approach as well.
extension View {
func transparentNonAnimatingFullScreenCover<Content: View>(isPresented: Binding<Bool>, content: #escaping () -> Content) -> some View {
modifier(TransparentNonAnimatableFullScreenModifier(isPresented: isPresented, fullScreenContent: content))
}
}
private struct TransparentNonAnimatableFullScreenModifier<FullScreenContent: View>: ViewModifier {
#Binding var isPresented: Bool
let fullScreenContent: () -> (FullScreenContent)
func body(content: Content) -> some View {
content
.onChange(of: isPresented) { isPresented in
UIView.setAnimationsEnabled(false)
}
.fullScreenCover(isPresented: $isPresented,
content: {
ZStack {
fullScreenContent()
}
.background(FullScreenCoverBackgroundRemovalView())
.onAppear {
if !UIView.areAnimationsEnabled {
UIView.setAnimationsEnabled(true)
}
}
.onDisappear {
if !UIView.areAnimationsEnabled {
UIView.setAnimationsEnabled(true)
}
}
})
}
}
private struct FullScreenCoverBackgroundRemovalView: UIViewRepresentable {
private class BackgroundRemovalView: UIView {
override func didMoveToWindow() {
super.didMoveToWindow()
superview?.superview?.backgroundColor = .clear
}
}
func makeUIView(context: Context) -> UIView {
return BackgroundRemovalView()
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

Asperi's answer is beautiful, but in case you want background to be transparent and not blurred, here is how you can modify it. I also moved the code into a modifier for convenience. (xcode 13.3, iOS 15.4.1)
extension View {
func transparentFullScreenCover<Content: View>(isPresented: Binding<Bool>, content: #escaping () -> Content) -> some View {
fullScreenCover(isPresented: isPresented) {
ZStack {
content()
}
.background(TransparentBackground())
}
}
}
struct TransparentBackground: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

I found this cleaner solution for the flicker issue in the clear background.
struct ClearBackgroundView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
return InnerView()
}
func updateUIView(_ uiView: UIView, context: Context) {
}
private class InnerView: UIView {
override func didMoveToWindow() {
super.didMoveToWindow()
superview?.superview?.backgroundColor = .clear
}
}
}
Usage
PresenterView()
.fullScreenCover(isPresented: $isPresented) {
PresentedView()
.background(ClearBackgroundView())
}

You can also use the Material background type:
ZStack{
...
}
.background(.ultraThinMaterial)
See documentation for more usage:
https://developer.apple.com/documentation/swiftui/material

Related

SwiftUI: Notification when .contextMenu is dismissed (iOS)

I'm using .contextMenu together with .onDrag on a view and this seems to be very tricky:
The background color changes to gray by setting dragging to true. This is triggered by .onDrag which already happens when opening the context menu (a bit early but ok). When I use the button to close the menu I can set dragging to false. When I use the drag, the dragging state is changed back to false when the ItemProvider is deinitialized. So far so good.
The problem
When I tap outside the context menu to dismiss it, I seem to have no way to set the dragging state back to false. Adding .onDisappear to the Button in the menu does not work.
What am I doing wrong here? Any way I can get either get notified when the context menu closes or have the state change of dragging happen when the drag actually begins (so that the background is not immediately gray when the context menu is opened)?
Code below video.
struct ContentView: View {
#State var dragging = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onDrag {
dragging = true
let provider = ItemProvider(contentsOf: URL(string: "Test")!)!
provider.didEnd = {
DispatchQueue.main.async {
dragging = false
}
}
print("init ItemProvider")
return provider
}
.contextMenu {
Button("Close Menu") {
dragging = false
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragging ? Color.gray : Color.white)
}
}
class ItemProvider: NSItemProvider {
var didEnd: (() -> Void)?
deinit {
print("deinit ItemProvider")
didEnd?()
}
}
EDIT (Dec. 2022): It seems like the code works in iOS 16.2. I still haven't found a good solution to this for earlier iOS versions.
Since you mention UIKit, .contextMenu is a UIContextMenuInteraction
You can add a UIContextMenuInteraction to a SwiftUI View and have access to UIContextMenuInteractionDelegate to identify when the menu is dismissed.
SwiftUI View > ViewModifier > UIViewRepresentable > Coordinator/UIContextMenuInteractionDelegate
struct CustomContextMenuView: View {
#State var dragging = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onDrag {
dragging = true
let provider = ItemProvider(contentsOf: URL(string: "Test")!)!
provider.didEnd = {
DispatchQueue.main.async {
dragging = false
}
}
print("init ItemProvider")
return provider
}
//Use custom context menu and add actions as [UIAction]
.contextMenu(actions: [
UIAction(title: "Close Menu", handler: { a in
print("Close Menu action")
dragging = false
})
], willEnd: {
//Called when the menu is dismissed
print("willEnd/onDismiss")
dragging = false
}, willDisplay: {
//Called when the menu appears
print("willDisplay/onAppear")
})
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragging ? Color.gray : Color.white)
}
}
The Menu can be implemented by pasting the code below in a .swift file and using it like the sample above.
extension View {
func contextMenu(actions: [UIAction], willEnd: (() -> Void)? = nil, willDisplay: (() -> Void)? = nil) -> some View {
modifier(ContextMenuViewModifier(actions: actions, willEnd: willEnd, willDisplay: willDisplay))
}
}
struct ContextMenuViewModifier: ViewModifier {
let actions: [UIAction]
let willEnd: (() -> Void)?
let willDisplay: (() -> Void)?
func body(content: Content) -> some View {
Interaction_UI(view: {content}, actions: actions, willEnd: willEnd, willDisplay: willDisplay)
.fixedSize()
}
}
struct Interaction_UI<Content2: View>: UIViewRepresentable{
typealias UIViewControllerType = UIView
#ViewBuilder var view: Content2
let actions: [UIAction]
let willEnd: (() -> Void)?
let willDisplay: (() -> Void)?
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIView(context: Context) -> some UIView {
let v = UIHostingController(rootView: view).view!
context.coordinator.contextMenu = UIContextMenuInteraction(delegate: context.coordinator)
v.addInteraction(context.coordinator.contextMenu!)
return v
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate{
var contextMenu: UIContextMenuInteraction!
let parent: Interaction_UI
init(parent: Interaction_UI) {
self.parent = parent
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [self]
suggestedActions in
return UIMenu(title: "", children: parent.actions)
})
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
print(#function)
parent.willDisplay?()
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
print(#function)
parent.willEnd?()
}
}
}
struct ContentView: View {
#State var dragging = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onDrag {
dragging = true
let provider = ItemProvider(contentsOf: URL(string: "Test")!)!
provider.didEnd = {
DispatchQueue.main.async {
dragging = false
}
}
print("init ItemProvider")
return provider
}
.contextMenu {
Button("Close Menu") {
dragging = false
}
}
.onTapGesture {
dragging = false
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragging ? Color.gray : Color.white)
}
}
class ItemProvider: NSItemProvider {
var didEnd: (() -> Void)?
deinit {
print("deinit ItemProvider")
didEnd?()
}
}

UIScrollView in SwiftUI, issues navigating using UIHostingController

I need the pull to refresh functionality from a UIScrollView in SwiftUI. So I have a rudimentary UIViewRepresentable adaption of the UIKit component into SwiftUI. But by using the UIHostingController, I'm not able to navigate off of the screen as I'm guessing it manages navigation now. Looking at Apple's documentation, UIHostingController is meant to be used to bring SwiftUI views into UIKit, not the other way around. But removing UIHostingController from RefreshableScrollView doesn't render the ScrollView on the screen.
Is there any way for me to successfully use UIScrollView in SwiftUI without navigation issues or without UIHostingController?
// ContentView.swift
struct ContentView: View {
var body: some View {
VStack {
GeometryReader { geometry in
RefreshableScrollView(size: geometry.size, refresh: self.refresh) {
Text("Placeholder text")
}
}
}
NavigationLink(destination: Text("Destination")) {
Text("Click me")
}
}
func refresh() {
print("refreshing")
}
}
// RefreshableScrollView.swift
import SwiftUI
import UIKit
struct RefreshableScrollView<Content: View>: UIViewRepresentable {
var width: CGFloat
var height: CGFloat
var refresh: () -> Void
var refreshViewController: UIHostingController<Content>
init(size: CGSize, refresh: #escaping () -> Void, #ViewBuilder content: () -> Content) {
self.width = size.width
self.height = size.height
self.refresh = refresh
self.refreshViewController = UIHostingController(rootView: content())
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.refreshControl = UIRefreshControl()
scrollView.refreshControl?.addTarget(
context.coordinator,
action: #selector(Coordinator.handleRefreshControl(sender:)),
for: .valueChanged
)
refreshViewController.view.frame = CGRect(x: 0, y: 0, width: self.width, height: self.height)
scrollView.addSubview(self.refreshViewController.view)
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var refreshScrollView: RefreshableScrollView
init(_ refreshScrollView: RefreshableScrollView) {
self.refreshScrollView = refreshScrollView
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
self.refreshScrollView.refresh()
sender.endRefreshing()
}
}
}
NavigationLink works inside NavigationView (except watchOS), so embed it as below
var body: some View {
NavigationView {
VStack {
GeometryReader { geometry in
RefreshableScrollView(size: geometry.size, refresh: self.refresh) {
Text("Placeholder text")
}
}
NavigationLink(destination: Text("Destination")) {
Text("Click me")
}
}
}
}

Support swipe to dismiss for UIViewControllerRepresentable presented in sheet

It seems if you use UIViewControllerRepresentable to implement a view controller in your SwiftUI app, when you present it via sheet you cannot swipe to dismiss it. Is there something you need to do to support swipe to dismiss?
struct ContentView: View {
#State var showingPicker = false
var body: some View {
Text("Hello, world!")
.onAppear {
showingPicker = true
}
.sheet(isPresented: $showingPicker, content: {
PHPicker() //cannot swipe to dismiss
//Text("Test") //can swipe to dismiss
})
}
}
struct PHPicker: UIViewControllerRepresentable {
func makeUIViewController(context: UIViewControllerRepresentableContext<PHPicker>) -> PHPickerViewController {
let config = PHPickerConfiguration()
return PHPickerViewController(configuration: config)
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: UIViewControllerRepresentableContext<PHPicker>) { }
}
Possible solution is to add something like handle to drag (no styling - simplified for demo),
.sheet(isPresented: $showingPicker, content: {
VStack {
RoundedRectangle(cornerRadius: 8).fill(Color.gray)
.frame(width: 60, height: 8)
.padding(.top, 8)
PHPicker()
}
})
Alternate: the solution is to make presentation by UIKit completely and just pass activation binding inside representable.
Here is a demo of possible approach. Tested with Xcode 12.1 / iOS 14.1
struct PHPickerContentView: View {
#State var showingPicker = false
var body: some View {
Button("Picker") {
showingPicker = true
}
.background(PHPicker(isPresented: $showingPicker)) // << here !!
}
}
struct PHPicker: UIViewControllerRepresentable {
#Binding var isPresented: Bool
func makeUIViewController(context: Context) -> UIViewController {
UIViewController() // << picker presenter
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// react on binding & show if not shown
if isPresented && uiViewController.presentedViewController == nil {
let config = PHPickerConfiguration()
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
uiViewController.present(picker, animated: true)
picker.presentationController?.delegate = context.coordinator
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate, UIAdaptivePresentationControllerDelegate {
let owner: PHPicker
init(_ owner: PHPicker) {
self.owner = owner
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// picked image handling code here
picker.presentingViewController?.dismiss(animated: true)
owner.isPresented = false // << reset on action !!
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
owner.isPresented = false // << reset on swipe !!
}
}
}

Collapse a doubleColumn NavigationView detail in SwiftUI like with collapsed on UISplitViewController?

So when I make a list in SwiftUI, I get the master-detail split view for "free".
So for instance with this:
import SwiftUI
struct ContentView : View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
NavigationView {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: Text("Hello!")) {
Text(person)
}
}
}
Text("🤪")
}
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
I get a splitView if an iPad simulator is in landscape, and the first detail screen is the emoji. But if people tap on a name, the detail view is "Hello!"
All that is great.
However, if I run the iPad in portrait, the user is greeted by the emoji, and then there is no indication that there is a list. You have to swipe from left to right to make the list appear from the side.
Does anyone know of a way to get even a navigation bar to appear that would let the user tap to see the list of items on the left? So that it's not a screen with the emoji only?
I would hate to leave a note that says "Swipe in from the left to see the list of files/people/whatever"
I remember UISplitViewController had a collapsed property that could be set. Is there anything like that here?
In Xcode 11 beta 3, Apple has added .navigationViewStyle(style:) to NavigationView.
Updated for Xcode 11 Beta 5.
create MasterView() & DetailsView().
struct MyMasterView: View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: DetailsView()) {
Text(person)
}
}
}
}
}
struct DetailsView: View {
var body: some View {
Text("Hello world")
.font(.largeTitle)
}
}
inside my ContentView :
var body: some View {
NavigationView {
MyMasterView()
DetailsView()
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
.padding()
}
Output:
For now, in Xcode 11.2.1 it is still nothing changed.
I had the same issue with SplitView on iPad and resolved it by adding padding like in Ketan Odedra response, but modified it a little:
var body: some View {
GeometryReader { geometry in
NavigationView {
MasterView()
DetailsView()
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.padding(.leading, leadingPadding(geometry))
}
}
private func leadingPadding(_ geometry: GeometryProxy) -> CGFloat {
if UIDevice.current.userInterfaceIdiom == .pad {
return 0.5
}
return 0
}
This works perfectly in the simulator. But when I submit my app for review, it was rejected. This little hack doesn't work on the reviewer device. I don't have a real iPad, so I don't know what caused this. Try it, maybe it will work for you.
While it doesn't work for me, I requested help from Apple DTS.
They respond to me that for now, SwiftUI API can't fully simulate UIKit`s SplitViewController behavior. But there is a workaround.
You can create custom SplitView in SwiftUI:
struct SplitView<Master: View, Detail: View>: View {
var master: Master
var detail: Detail
init(#ViewBuilder master: () -> Master, #ViewBuilder detail: () -> Detail) {
self.master = master()
self.detail = detail()
}
var body: some View {
let viewControllers = [UIHostingController(rootView: master), UIHostingController(rootView: detail)]
return SplitViewController(viewControllers: viewControllers)
}
}
struct SplitViewController: UIViewControllerRepresentable {
var viewControllers: [UIViewController]
#Environment(\.splitViewPreferredDisplayMode) var preferredDisplayMode: UISplitViewController.DisplayMode
func makeUIViewController(context: Context) -> UISplitViewController {
return UISplitViewController()
}
func updateUIViewController(_ splitController: UISplitViewController, context: Context) {
splitController.preferredDisplayMode = preferredDisplayMode
splitController.viewControllers = viewControllers
}
}
struct PreferredDisplayModeKey : EnvironmentKey {
static var defaultValue: UISplitViewController.DisplayMode = .automatic
}
extension EnvironmentValues {
var splitViewPreferredDisplayMode: UISplitViewController.DisplayMode {
get { self[PreferredDisplayModeKey.self] }
set { self[PreferredDisplayModeKey.self] = newValue }
}
}
extension View {
/// Sets the preferred display mode for SplitView within the environment of self.
func splitViewPreferredDisplayMode(_ mode: UISplitViewController.DisplayMode) -> some View {
self.environment(\.splitViewPreferredDisplayMode, mode)
}
}
And then use it:
SplitView(master: {
MasterView()
}, detail: {
DetailView()
}).splitViewPreferredDisplayMode(.allVisible)
On an iPad, it works. But there is one issue (maybe more..).
This approach ruins navigation on iPhone because both MasterView and DetailView have their NavigationView.
UPDATE:
Finally, in Xcode 11.4 beta 2 they added a button in Navigation Bar that indicates hidden master view.
Minimal testing in the Simulator, but this should be close to a real solution. The idea is to use an EnvironmentObject to hold a published var on whether to use a double column NavigationStyle, or a single one, then have the NavigationView get recreated if that var changes.
The EnvironmentObject:
final class AppEnvironment: ObservableObject {
#Published var useSideBySide: Bool = false
}
In the Scene Delegate, set the variable at launch, then observe device rotations and possibly change it (the "1000" is not the correct value, starting point):
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var appEnvironment = AppEnvironment()
#objc
func orientationChanged() {
let bounds = UIScreen.main.nativeBounds
let orientation = UIDevice.current.orientation
// 1000 is a starting point, should be smallest height of a + size iPhone
if orientation.isLandscape && bounds.size.height > 1000 {
if appEnvironment.useSideBySide == false {
appEnvironment.useSideBySide = true
print("SIDE changed to TRUE")
}
} else if orientation.isPortrait && appEnvironment.useSideBySide == true {
print("SIDE changed to false")
appEnvironment.useSideBySide = false
}
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(appEnvironment))
self.window = window
window.makeKeyAndVisible()
orientationChanged()
NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil)
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
}
In the top level content view, where the NavigationView is created, use a custom modifier instead of using a navigationViewStyle directly:
struct ContentView: View {
#State private var dates = [Date]()
var body: some View {
NavigationView {
MV(dates: $dates)
DetailView()
}
.modifier( WTF() )
}
struct WTF: ViewModifier {
#EnvironmentObject var appEnvironment: AppEnvironment
func body(content: Content) -> some View {
Group {
if appEnvironment.useSideBySide == true {
content
.navigationViewStyle(DoubleColumnNavigationViewStyle())
} else {
content
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
}
}
As mentioned earlier, just Simulator testing, but I tried launching in both orientations, rotating with Master showing, rotating with Detail showing, it all looks good to me.
For current version (iOS 13.0-13.3.x), you can use my code.
I use a UIViewUpdater to access the underlaying UIView and its UIViewController to adjust the bar item.
I think the UIViewUpdater way to solve this problem is the most Swifty and robust way, and you can use it to access and modify other UIView, UIViewController related UIKit mechanism.
ContentView.swift
import SwiftUI
struct ContentView : View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
NavigationView {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: DetailView()) { Text(person) }
}
}
InitialDetailView()
}
}
}
struct DetailView : View {
var body: some View {
Text("Hello!")
}
}
struct InitialDetailView : View {
var body: some View {
NavigationView {
Text("🤪")
.navigationBarTitle("", displayMode: .inline) // .inline is neccesary for showing the left button item
.updateUIViewController {
$0.splitViewController?.preferredDisplayMode = .primaryOverlay // for showing overlay at initial
$0.splitViewController?.preferredDisplayMode = .automatic
}
.displayModeButtonItem()
}.navigationViewStyle(StackNavigationViewStyle())
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
Utility code for the solution. Put it in any Swift file for the project.
Utility.swift
// View decoration
public extension View {
func updateUIView(_ action: #escaping (UIView) -> Void) -> some View {
background(UIViewUpdater(action: action).opacity(0))
}
func updateUIViewController(_ action: #escaping (UIViewController) -> Void) -> some View {
updateUIView {
guard let viewController = $0.viewController else { return }
action(viewController)
}
}
func displayModeButtonItem(_ position: NavigationBarPostion = .left) -> some View {
updateUIViewController { $0.setDisplayModeButtonItem(position) }
}
}
// UpdateUIView
struct UIViewUpdater : UIViewRepresentable {
let action: (UIView) -> Void
typealias UIViewType = InnerUIView
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIViewType {
UIViewType(action: action)
}
func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Self>) {
// DispatchQueue.main.async { [action] in action(uiView) }
}
class InnerUIView : UIView {
let action: (UIView) -> Void
init(action: #escaping (UIView) -> Void) {
self.action = action
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMoveToWindow() {
super.didMoveToWindow()
update()
}
func update() {
action(self)
}
}
}
// UIView.viewController
public extension UIView {
var viewController: UIViewController? {
var i: UIResponder? = self
while i != nil {
if let vc = i as? UIViewController { return vc }
i = i?.next
}
return nil
}
}
// UIViewController.setDisplayModeButtonItem
public enum NavigationBarPostion {
case left
case right
}
public extension UIViewController {
func setDisplayModeButtonItem(_ position: NavigationBarPostion) {
guard let splitViewController = splitViewController else { return }
switch position {
case .left:
// keep safe to avoid replacing other left bar button item, e.g. navigation back
navigationItem.leftItemsSupplementBackButton = true
navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
case .right:
navigationItem.rightBarButtonItem = splitViewController.displayModeButtonItem
}
}
}
import SwiftUI
var hostingController: UIViewController?
func showList() {
let split = hostingController?.children[0] as? UISplitViewController
UIView.animate(withDuration: 0.3, animations: {
split?.preferredDisplayMode = .primaryOverlay
}) { _ in
split?.preferredDisplayMode = .automatic
}
}
func hideList() {
let split = hostingController?.children[0] as? UISplitViewController
split?.preferredDisplayMode = .primaryHidden
}
// =====
struct Dest: View {
var person: String
var body: some View {
VStack {
Text("Hello! \(person)")
Button(action: showList) {
Image(systemName: "sidebar.left")
}
}
.onAppear(perform: hideList)
}
}
struct ContentView : View {
var people = ["Angela", "Juan", "Yeji"]
var body: some View {
NavigationView {
List {
ForEach(people, id: \.self) { person in
NavigationLink(destination: Dest(person: person)) {
Text(person)
}
}
}
VStack {
Text("🤪")
Button(action: showList) {
Image(systemName: "sidebar.left")
}
}
}
}
}
import PlaygroundSupport
hostingController = UIHostingController(rootView: ContentView())
PlaygroundPage.current.setLiveView(hostingController!)

Prevent dismissal of modal view controller in SwiftUI

At WWDC 2019, Apple announced a new "card-style" look for modal presentations, which brought along with it built-in gestures for dismissing modal view controllers by swiping down on the card. They also introduced the new isModalInPresentation property on UIViewController so that you can disallow this dismissal behavior if you so choose.
So far, though, I have found no way to emulate this behavior in SwiftUI. Using the .presentation(_ modal: Modal?), does not, as far as I can tell, allow you to disable the dismissal gestures in the same way. I also attempted putting the modal view controller inside a UIViewControllerRepresentable View, but that didn't seem to help either:
struct MyViewControllerView: UIViewControllerRepresentable {
func makeUIViewController(context: UIViewControllerRepresentableContext<MyViewControllerView>) -> UIHostingController<MyView> {
return UIHostingController(rootView: MyView())
}
func updateUIViewController(_ uiViewController: UIHostingController<MyView>, context: UIViewControllerRepresentableContext<MyViewControllerView>) {
uiViewController.isModalInPresentation = true
}
}
Even after presenting with .presentation(Modal(MyViewControllerView())) I was able to swipe down to dismiss the view. Is there currently any way to do this with existing SwiftUI constructs?
Update for iOS 15
As per pawello2222's answer below, this is now supported by the new interactiveDismissDisabled(_:) API.
struct ContentView: View {
#State private var showSheet = false
var body: some View {
Text("Content View")
.sheet(isPresented: $showSheet) {
Text("Sheet View")
.interactiveDismissDisabled(true)
}
}
}
Pre-iOS-15 answer
I wanted to do this as well, but couldn't find the solution anywhere. The answer that hijacks the drag gesture kinda works, but not when it's dismissed by scrolling a scroll view or form. The approach in the question is less hacky also, so I investigated it further.
For my use case I have a form in a sheet which ideally could be dismissed when there's no content, but has to be confirmed through a alert when there is content.
My solution for this problem:
struct ModalSheetTest: View {
#State private var showModally = false
#State private var showSheet = false
var body: some View {
Form {
Toggle(isOn: self.$showModally) {
Text("Modal")
}
Button(action: { self.showSheet = true}) {
Text("Show sheet")
}
}
.sheet(isPresented: $showSheet) {
Form {
Button(action: { self.showSheet = false }) {
Text("Hide me")
}
}
.presentation(isModal: self.showModally) {
print("Attempted to dismiss")
}
}
}
}
The state value showModally determines if it has to be showed modally. If so, dragging it down to dismiss will only trigger the closure which just prints "Attempted to dismiss" in the example, but can be used to show the alert to confirm dismissal.
struct ModalView<T: View>: UIViewControllerRepresentable {
let view: T
let isModal: Bool
let onDismissalAttempt: (()->())?
func makeUIViewController(context: Context) -> UIHostingController<T> {
UIHostingController(rootView: view)
}
func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
context.coordinator.modalView = self
uiViewController.rootView = view
uiViewController.parent?.presentationController?.delegate = context.coordinator
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
let modalView: ModalView
init(_ modalView: ModalView) {
self.modalView = modalView
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
!modalView.isModal
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
modalView.onDismissalAttempt?()
}
}
}
extension View {
func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View {
ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt)
}
}
This is perfect for my use case, hope it helps you or someone else out as well.
By changing the gesture priority of any view you don't want to be dragged, you can prevent DragGesture on any view. For example for Modal it can be done as bellow:
Maybe it is not a best practice, but it works perfectly
struct ContentView: View {
#State var showModal = true
var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Show Modal")
}.sheet(isPresented: self.$showModal) {
ModalView()
}
}
}
struct ModalView : View {
#Environment(\.presentationMode) var presentationMode
let dg = DragGesture()
var body: some View {
ZStack {
Rectangle()
.fill(Color.white)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.highPriorityGesture(dg)
Button("Dismiss Modal") {
self.presentationMode.wrappedValue.dismiss()
}
}
}
}
Note: This code has been edited for clarity and brevity.
Using a way to get the current window scene from here you can get the top view controller by this extension here from #Bobj-C
extension UIApplication {
func visibleViewController() -> UIViewController? {
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
guard let rootViewController = window.rootViewController else { return nil }
return UIApplication.getVisibleViewControllerFrom(vc: rootViewController)
}
private static func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
if let navigationController = vc as? UINavigationController,
let visibleController = navigationController.visibleViewController {
return UIApplication.getVisibleViewControllerFrom( vc: visibleController )
} else if let tabBarController = vc as? UITabBarController,
let selectedTabController = tabBarController.selectedViewController {
return UIApplication.getVisibleViewControllerFrom(vc: selectedTabController )
} else {
if let presentedViewController = vc.presentedViewController {
return UIApplication.getVisibleViewControllerFrom(vc: presentedViewController)
} else {
return vc
}
}
}
}
and turn it into a view modifier like this:
struct DisableModalDismiss: ViewModifier {
let disabled: Bool
func body(content: Content) -> some View {
disableModalDismiss()
return AnyView(content)
}
func disableModalDismiss() {
guard let visibleController = UIApplication.shared.visibleViewController() else { return }
visibleController.isModalInPresentation = disabled
}
}
and use like:
struct ShowSheetView: View {
#State private var showSheet = true
var body: some View {
Text("Hello, World!")
.sheet(isPresented: $showSheet) {
TestView()
.modifier(DisableModalDismiss(disabled: true))
}
}
}
For everyone who has problems with #Guido's solution and NavigationView. Just combine the solution of #Guido and #SlimeBaron
class ModalHostingController<Content: View>: UIHostingController<Content>, UIAdaptivePresentationControllerDelegate {
var canDismissSheet = true
var onDismissalAttempt: (() -> ())?
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
parent?.presentationController?.delegate = self
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
canDismissSheet
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
onDismissalAttempt?()
}
}
struct ModalView<T: View>: UIViewControllerRepresentable {
let view: T
let canDismissSheet: Bool
let onDismissalAttempt: (() -> ())?
func makeUIViewController(context: Context) -> ModalHostingController<T> {
let controller = ModalHostingController(rootView: view)
controller.canDismissSheet = canDismissSheet
controller.onDismissalAttempt = onDismissalAttempt
return controller
}
func updateUIViewController(_ uiViewController: ModalHostingController<T>, context: Context) {
uiViewController.rootView = view
uiViewController.canDismissSheet = canDismissSheet
uiViewController.onDismissalAttempt = onDismissalAttempt
}
}
extension View {
func interactiveDismiss(canDismissSheet: Bool, onDismissalAttempt: (() -> ())? = nil) -> some View {
ModalView(
view: self,
canDismissSheet: canDismissSheet,
onDismissalAttempt: onDismissalAttempt
).edgesIgnoringSafeArea(.all)
}
}
Usage:
struct ContentView: View {
#State var isPresented = false
#State var canDismissSheet = false
var body: some View {
Button("Tap me") {
isPresented = true
}
.sheet(
isPresented: $isPresented,
content: {
NavigationView {
Text("Hello World")
}
.interactiveDismiss(canDismissSheet: canDismissSheet) {
print("attemptToDismissHandler")
}
}
)
}
}
As of iOS 14, you can use .fullScreenCover(isPresented:, content:) (Docs) instead of .sheet(isPresented:, content:) if you don't want the dismissal gestures.
struct FullScreenCoverPresenterView: View {
#State private var isPresenting = false
var body: some View {
Button("Present Full-Screen Cover") {
isPresenting.toggle()
}
.fullScreenCover(isPresented: $isPresenting) {
Text("Tap to Dismiss")
.onTapGesture {
isPresenting.toggle()
}
}
}
}
Note: fullScreenCover is unavailable on macOS, but it works well on iPhone and iPad.
Note: This solution doesn't allow you to enable the dismissal gesture when a certain condition is met. To enable and disable the dismissal gesture with a condition, see my other answer.
iOS 15+
Starting from iOS 15 we can use interactiveDismissDisabled:
func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View
We just need to attach it to the sheet:
struct ContentView: View {
#State private var showSheet = false
var body: some View {
Text("Content View")
.sheet(isPresented: $showSheet) {
Text("Sheet View")
.interactiveDismissDisabled(true)
}
}
}
If needed, you can also pass a variable to control when the sheet can be disabled:
.interactiveDismissDisabled(!userAcceptedTermsOfUse)
You can use this method to pass the content of the modal view for reuse.
Use NavigationView with gesture priority to disable dragging.
import SwiftUI
struct ModalView<Content: View>: View
{
#Environment(\.presentationMode) var presentationMode
let content: Content
let title: String
let dg = DragGesture()
init(title: String, #ViewBuilder content: #escaping () -> Content) {
self.content = content()
self.title = title
}
var body: some View
{
NavigationView
{
ZStack (alignment: .top)
{
self.content
}
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: {
ToolbarItem(placement: .principal, content: {
Text(title)
})
ToolbarItem(placement: .navigationBarTrailing, content: {
Button("Done") {
self.presentationMode.wrappedValue.dismiss()
}
})
})
}
.highPriorityGesture(dg)
}
}
In Content View:
struct ContentView: View {
#State var showModal = true
var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Show Modal")
}.sheet(isPresented: self.$showModal) {
ModalView (title: "Title") {
Text("Prevent dismissal of modal view.")
}
}
}
}
Result!
We have created an extension to make controlling the modal dismission effortless, at https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0
/// Example:
struct ContentView: View {
#State private var presenting = false
var body: some View {
VStack {
Button {
presenting = true
} label: {
Text("Present")
}
}
.sheet(isPresented: $presenting) {
ModalContent()
.allowAutoDismiss { false }
// or
// .allowAutoDismiss(false)
}
}
}
This solution worked for me on iPhone and iPad. It uses isModalInPresentation. From the docs:
The default value of this property is false. When you set it to true, UIKit ignores events outside the view controller's bounds and prevents the interactive dismissal of the view controller while it is onscreen.
Your attempt is close to what worked for me. The trick is setting isModalInPresentation on the hosting controller's parent in willMove(toParent:)
class MyHostingController<Content: View>: UIHostingController<Content> {
var canDismissSheet = true
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
parent?.isModalInPresentation = !canDismissSheet
}
}
struct MyViewControllerView<Content: View>: UIViewControllerRepresentable {
let content: Content
let canDismissSheet: Bool
func makeUIViewController(context: Context) -> UIHostingController<Content> {
let viewController = MyHostingController(rootView: content)
viewController.canDismissSheet = canDismissSheet
return viewController
}
func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
uiViewController.parent?.isModalInPresentation = !canDismissSheet
}
}
it supports most of the iOS version
no need of making wrappers just do this
extension UINavigationController {
open override func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.isEnabled = false
interactivePopGestureRecognizer?.delegate = nil
}}

Resources