Problem when trying to hide tab bar SwiftUI - ios

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()
}
}
}

Related

SwiftUI modal view

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

UIScrollView in SwiftUI with dynamic content wrong length

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:

Why does SwiftUI animation/transition happen instantly?

Trying to build an app navigation view that does programmatic push/pop animations with given views. The main animation happens in containedView() where I show the new view and hide the current view with animation. The problem I have is that the animation happens instantly, so it doesn't happen at all.
I am curious how to debug this or if anyone has any recommendations on how to fix this?
enum AppNavigationState
{
case root
case push(currentView: AnyView, newView: AnyView)
case pop(currentView: AnyView, newView: AnyView)
}
class AppNavigationViewModel: ObservableObject {
public var navigationStack: [AnyView]
#Published public var navigationState: AppNavigationState
init(rootView: AnyView) {
navigationStack = [rootView]
navigationState = AppNavigationState.root
}
}
struct AppNavigationView: View {
#ObservedObject var appNavigationViewModel: AppNavigationViewModel
var body: some View {
containedView()
}
private func containedView() -> AnyView {
switch(appNavigationViewModel.navigationState) {
case .root:
return appNavigationViewModel.navigationStack.last!
case let .pop(currentView, newView):
return AnyView(ZStack {
newView
currentView
.transition(AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
.animation(.easeOut)
})
case let .push(currentView, newView):
return AnyView(ZStack {
currentView
newView
.transition(AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
.animation(.easeOut)
})
}
}
public func pushView(view:AnyView) {
if let currentView = appNavigationViewModel.navigationStack.last {
appNavigationViewModel.navigationStack.append(view)
appNavigationViewModel.navigationState = .push(currentView: currentView, newView: view)
}
}
public func popView() {
if (appNavigationViewModel.navigationStack.count > 1) {
let currentView = appNavigationViewModel.navigationStack.removeLast()
let newView = appNavigationViewModel.navigationStack.last!
appNavigationViewModel.navigationState = .push(currentView: currentView, newView: newView)
}
}
}

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