What is the best way to modally show a SwiftUI view from any class or structure? I use a UIHostingController from UIKit. Is there any better way to do this using only SwiftUI?
ContentView with buttons to present the SwiftUI view
struct ContentView: View {
var body: some View {
Button {
// Present the view
presentView(controller: UIHostingController(rootView: view))
} label: {
Text("Present view")
}
}
var view: some View {
Button {
// Dismiss the view
dismissView()
} label: {
Rectangle()
.overlay(
Text("Dismiss view")
)
}
}
}
The functions used to present the SwiftUI view
extension ContentView {
// Returns the top view controller
func topViewController() -> UIViewController? {
let keyWindow = UIApplication.shared.windows.filter {$0.isKeyWindow}.first
if var topController = keyWindow?.rootViewController {
while let presentedViewController = topController.presentedViewController { topController = presentedViewController }
return topController
} else { return nil }
}
// Presents the SwiftUI view in a UIHostingController
func presentView(controller: UIViewController) {
controller.view.backgroundColor = .none
controller.modalPresentationStyle = .overCurrentContext
topViewController()?.present(controller, animated: false, completion: nil)
}
// Removes the UIHostingViewController from root view
func dismissView() {
topViewController()?.dismiss(animated: false, completion: nil)
}
}
You can present modal sheet like this:
struct ContentView: View {
#State private var showSheet = false
var body: some View {
Button("Present") {
showSheet.toggle()
}.font(.largeTitle)
.sheet(isPresented: $showSheet) {
SheetView()
}
}
}
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
ZStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle")
.font(.largeTitle)
.foregroundColor(.gray)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding()
}
}
Or present modal on full screen like this:
struct ContentView: View {
#State private var showSheet = false
var body: some View {
Button("Present") {
showSheet.toggle()
}.font(.largeTitle)
.fullScreenCover(isPresented: $showSheet) {
SheetView()
}
}
}
struct SheetView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
ZStack {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Image(systemName: "xmark.circle")
.font(.largeTitle)
.foregroundColor(.gray)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding()
}
}
For your comment it's no problem, you can just do this:
struct ContentView: View {
#State private var showSheet = false
var body: some View {
ZStack {
Button("Present") {
showSheet.toggle()
}
.font(.largeTitle)
if showSheet {
ZStack {
Button {
showSheet.toggle()
} label: {
Image(systemName: "xmark.circle")
.font(.largeTitle)
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding()
}
.background(.ultraThickMaterial)
}
}
}
}
Related
There have been several questions like this one. There is a small difference which I didn't notice in any other answers. Basically I have a TabView and EACH of its items is wrapped inside NavigationView. Because it is done this way and not the other way around first NavigationView and than TabView the way to hide the view is not that simple.
extension UIView {
func allSubviews() -> [UIView] {
var allSubviews = subviews
for subview in subviews {
allSubviews.append(contentsOf: subview.allSubviews())
}
return allSubviews
}
}
extension UITabBar {
private static var originalY: Double?
static public func changeTabBarState(shouldHide: Bool) {
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
windowScene?.windows.first(where: { $0.isKeyWindow })?.allSubviews().forEach({ view in
if let tabBar = view as? UITabBar {
if !tabBar.isHidden && shouldHide {
originalY = tabBar.frame.origin.y
tabBar.frame.origin.y = UIScreen.main.bounds.height + 200
} else if tabBar.isHidden && !shouldHide {
guard let originalY else {
return
}
tabBar.frame.origin.y = originalY
}
tabBar.isHidden = shouldHide
}
})
}
}
struct MyTabView: View {
#Environment(\.colorScheme) var colorScheme
#State var toggle = false
var body: some View {
TabView {
NavigationView {
ContentView(toggle: $toggle)
}
.tabItem {
Text("Profile")
.foregroundColor(colorScheme == .dark ? .white : .black)
}
}
.accentColor(colorScheme == .dark ? .white : .black)
.navigationBarTitleDisplayMode(.inline)
}
}
struct ContentView: View {
#Binding var toggle: Bool
var body: some View {
NavigationLink(isActive: $toggle, destination: {
Button("dismiss") {
UITabBar.changeTabBarState(shouldHide: false)
toggle.toggle()
}
.navigationBarBackButtonHidden()
.navigationBarHidden(true)
.onAppear {
UITabBar.changeTabBarState(shouldHide: true)
}
}, label: {
Text("Click")
})
}
}
#main
struct AppTestingOne: App {
var body: some Scene {
WindowGroup {
MyTabView()
}
}
}
The problem I get when doing this is the following. When I click the button at first screen I get sent here:
On first look there doesn't seem to be a problem. In fact the button dismiss has like an invisible padding from the bottom. The interesting part is that when I rotate to landscape back to fullscreen, this padding disappears.
When you look at these pictures there doesn't seem to be a problem, but when you have something at the top and at the bottom it gets pushed up and fixed when you re-rotate it.
If you're building for iOS 16, you can simply use
.toolbar(.hidden, for: .tabBar)
Example:
struct ContentView: View {
var body: some View {
TabView() {
FirstScreen()
NavigationStack {
Text("second")
.navigationTitle("second")
}
.tabItem {
Label("second", systemImage: "2.circle")
}
NavigationStack {
Text("third")
.navigationTitle("third")
}
.tabItem {
Label("third", systemImage: "3.circle")
}
}
}
}
struct FirstScreen: View {
#State private var hideTabBar = false
var body: some View {
NavigationStack() {
VStack {
Button("Toggle tab bar") {
withAnimation {
hideTabBar.toggle()
}
}
.padding()
Spacer()
}
.navigationTitle("first")
}
.toolbar(hideTabBar ? .hidden : .visible, for: .tabBar)
.tabItem {
Label("first", systemImage: "1.circle")
}
}
}
I found out a very cool solution. When I hide the tabBar I can push its superview down depending on the phone (formula needs to be calculated) and after rotation it continues to work just fine by ignoring by how much I have pushed it and going back to the way it should be which because I calculated it for iPhone 12,13,14 and it works just as fine.
extension UIView {
func allSubviews() -> [UIView] {
var allSubviews = subviews
for subview in subviews {
allSubviews.append(contentsOf: subview.allSubviews())
}
return allSubviews
}
}
extension UITabBar {
private static var originalY: Double?
static public func changeTabBarState(shouldHide: Bool) {
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
windowScene?.windows.first(where: { $0.isKeyWindow })?.allSubviews().forEach({ view in
if let tabBar = view as? UITabBar {
if !tabBar.isHidden && shouldHide {
originalY = (tabBar.superview?.frame.origin.y)!
tabBar.superview?.frame.origin.y = (tabBar.superview?.frame.origin.y)! + 4.5
} else if tabBar.isHidden && !shouldHide {
guard let originalY else {
return
}
tabBar.superview?.frame.origin.y = originalY
}
tabBar.isHidden = shouldHide
tabBar.superview?.setNeedsLayout()
tabBar.superview?.layoutIfNeeded()
}
})
}
}
struct MyTabView: View {
#Environment(\.colorScheme) var colorScheme
#State var toggle = false
var body: some View {
TabView {
NavigationView {
RandomView(toggle: $toggle)
}
.tabItem {
Text("Profile")
.foregroundColor(colorScheme == .dark ? .white : .black)
}
}
.accentColor(colorScheme == .dark ? .white : .black)
.navigationBarTitleDisplayMode(.inline)
}
}
struct RandomView: View {
#Binding var toggle: Bool
var body: some View {
NavigationLink(isActive: $toggle, destination: {
Button("dismiss") {
UITabBar.changeTabBarState(shouldHide: false)
toggle.toggle()
}
.navigationBarBackButtonHidden()
.navigationBarHidden(true)
.onAppear {
UITabBar.changeTabBarState(shouldHide: true)
}
}, label: {
Text("Click")
})
}
}
#main
struct AppTestingTwo: App {
var body: some Scene {
WindowGroup {
MyTabView()
}
}
}
In SwiftUI, normal keyboards come with return key which can be used to dismiss the keyboard. However, for specific keyboard types like phonePad there is no return key.
There are few hacks using which we can add accessory view, but, is there any SwiftUI way of handling this?
Here is my code
TextField("Phone Number", text: .constant("12345"))
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
There is no simple way but IMO the best option is to add a toolbar as shown here:
import SwiftUI
struct ContentView: View {
#State private var phoneNumber = ""
var body: some View {
NavigationView {
KeyboardView {
HStack {
Spacer()
TextField("Phone Number", text: $phoneNumber)
.padding()
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary, lineWidth: 1)
.frame(height: 50))
.keyboardType(.phonePad)
Spacer()
}
} toolBar: {
HStack {
Spacer()
Button {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
} label: { Text("Done") }
}.padding()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class KeyboardResponder: ObservableObject {
private var notificationCenter: NotificationCenter
#Published private(set) var height: CGFloat = .zero
init(center: NotificationCenter = .default) {
notificationCenter = center
notificationCenter.addObserver(self, selector: #selector(keyBoardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(keyBoardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
func dismiss() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
deinit { notificationCenter.removeObserver(self) }
#objc func keyBoardWillShow(_ notification: Notification) {
height = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height ?? .zero
}
#objc func keyBoardWillHide(_ notification: Notification) {
height = .zero
}
}
struct KeyboardView<Content, ToolBar>: View where Content: View, ToolBar: View {
#StateObject private var keyboard = KeyboardResponder()
let toolbarFrame = CGSize(width: UIScreen.main.bounds.width, height: 40)
var content: () -> Content
var toolBar: () -> ToolBar
var body: some View {
ZStack {
content().padding(.bottom, keyboard.height == .zero ? .zero : toolbarFrame.height)
VStack {
Spacer()
toolBar()
.frame(width: toolbarFrame.width, height: toolbarFrame.height)
.background(Color.secondary)
}
.opacity(keyboard.height == .zero ? .zero : 1)
.animation(.easeOut)
}
.padding(.bottom, keyboard.height)
.edgesIgnoringSafeArea(.bottom)
.animation(.easeOut)
}
}
The solution I've used is an onTapGesture() on the container of my app. You can set the focused state of the input to false on a tap outside of the keyboard like so:
struct ContentView: View{
#FocusState private var focusItem: Bool
var body: some View {
VStack {
TextField("", value: $someValue, format: .number)
.onSubmit {focusItem = false}
.keyboardType(.number)
.focused($focusItem)
}.onTapGesture{
focusItem = false
}
}
}
simply tap the screen to drop the keyboard (note this is for .number type keyboard but I expect it to work the same)
I'm trying to add UIScrollView supporting dynamic content to a SwiftUI project. My problem is that if the content changes, the UIScrollView does not update its length. In this example, the circles will get cut off. It's probably pretty simple, but I haven't found anything on this topic online. How can I solve this?
struct ContentView: View {
#State var count = 5
var body: some View {
GeometryReader{ geometry in
VStack{
Spacer()
UIScrollViewWrapper(pagingEnabled: false){
HStack(spacing: 10){
ForEach(0..<self.count, id: \.self) { item in
Circle()
.foregroundColor(.green)
.frame(width: 80, height: 50)
}
}
}
Spacer()
Button(action:{
self.count += 1
})
{
Text("Increase Circle Count")
}
Spacer()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
var pagingEnabled: Bool
init(pagingEnabled: Bool, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.pagingEnabled = pagingEnabled
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
vc.pagingEnabled = self.pagingEnabled
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
class UIScrollViewViewController: UIViewController {
var pagingEnabled = false
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = self.pagingEnabled
v.showsHorizontalScrollIndicator = false
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
If there is no specific reason for you to use UIScrollViewWrapper you can use the SwiftUI native ScrollView component. This updates the view as required:
(Only this code needed)
struct ContentView: View {
#State var count = 5
var body: some View {
GeometryReader{ geometry in
VStack{
Spacer()
// Change here
ScrollView(.horizontal, showsIndicators: false){
HStack(spacing: 10){
ForEach(0..<self.count, id: \.self) { item in
Circle()
.foregroundColor(.green)
.frame(width: 80, height: 50)
.overlay(Text(String(item)))
}
}
}
Spacer()
Button(action:{
self.count += 1
})
{
Text("Increase Circle Count")
}
Text(String(self.count))
Spacer()
}
}
}
}
See here:
I'm trying to set a certain size for a popover or to make it adapt its content
I tried to change the frame for the view from popover, but it does not seem to work
Button("Popover") {
self.popover7.toggle()
}.popover(isPresented: self.$popover7, arrowEdge: .bottom) {
PopoverView().frame(width: 100, height: 100, alignment: .center)
}
I'd like to achieve this behaviour I found in Calendar app in iPad
The solution by #ccwasden works very well. I extended his work by making it more "natural" in terms of SwiftUI. Also, this version utilizes sizeThatFits method, so you don't have to specify the size of the popover content.
struct PopoverViewModifier<PopoverContent>: ViewModifier where PopoverContent: View {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let content: () -> PopoverContent
func body(content: Content) -> some View {
content
.background(
Popover(
isPresented: self.$isPresented,
onDismiss: self.onDismiss,
content: self.content
)
)
}
}
extension View {
func popover<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: #escaping () -> Content
) -> some View where Content: View {
ModifiedContent(
content: self,
modifier: PopoverViewModifier(
isPresented: isPresented,
onDismiss: onDismiss,
content: content
)
)
}
}
struct Popover<Content: View> : UIViewControllerRepresentable {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
#ViewBuilder let content: () -> Content
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, content: self.content())
}
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
context.coordinator.host.rootView = self.content()
if self.isPresented, uiViewController.presentedViewController == nil {
let host = context.coordinator.host
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: Int.max, height: Int.max))
host.modalPresentationStyle = UIModalPresentationStyle.popover
host.popoverPresentationController?.delegate = context.coordinator
host.popoverPresentationController?.sourceView = uiViewController.view
host.popoverPresentationController?.sourceRect = uiViewController.view.bounds
uiViewController.present(host, animated: true, completion: nil)
}
}
class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
let host: UIHostingController<Content>
private let parent: Popover
init(parent: Popover, content: Content) {
self.parent = parent
self.host = UIHostingController(rootView: content)
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.parent.isPresented = false
if let onDismiss = self.parent.onDismiss {
onDismiss()
}
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
}
}
I got it to work on iOS with a custom UIViewRepresentable. Here is what the usage looks like:
struct Content: View {
#State var open = false
#State var popoverSize = CGSize(width: 300, height: 300)
var body: some View {
WithPopover(
showPopover: $open,
popoverSize: popoverSize,
content: {
Button(action: { self.open.toggle() }) {
Text("Tap me")
}
},
popoverContent: {
VStack {
Button(action: { self.popoverSize = CGSize(width: 300, height: 600)}) {
Text("Increase size")
}
Button(action: { self.open = false}) {
Text("Close")
}
}
})
}
}
And here is a gist with the source for WithPopover
macOS-only
Here is how to change frame of popover dynamically... for simplicity it is w/o animation, it is up to you.
struct TestCustomSizePopover: View {
#State var popover7 = false
var body: some View {
VStack {
Button("Popover") {
self.popover7.toggle()
}.popover(isPresented: self.$popover7, arrowEdge: .bottom) {
PopoverView()
}
}.frame(width: 800, height: 600)
}
}
struct PopoverView: View {
#State var adaptableHeight = CGFloat(100)
var body: some View {
VStack {
Text("Popover").padding()
Button(action: {
self.adaptableHeight = 300
}) {
Text("Button")
}
}
.frame(width: 100, height: adaptableHeight)
}
}
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
}}