SwiftUI: How to ignore taps on background when menu is open? - ios

I am currently struggling to resolve a SwiftUI issue:
In a very abstract way, this is how the code of my application looks like (not the actual code to simply things for the discussion here):
struct SwiftUIView: View {
#State private var toggle: Bool = true
var body: some View {
VStack {
Spacer()
if toggle {
Text("on")
} else {
Text("off")
}
Spacer()
Rectangle()
.frame(height: 200)
.onTapGesture { toggle.toggle() }
Spacer()
Menu("Actions") {
Button("Duplicate", action: { toggle.toggle() })
Button("Rename", action: { toggle.toggle() })
Button("Delete", action: { toggle.toggle() })
}
Spacer()
}
}
}
So what's the essence here?
There is an element (rectangle) in the background that reacts to tap input from the user
There is a menu that contains items that also carry out some action when tapped
Now, I am facing the following issue:
When opening the menu by tapping on "Actions" the menu opens up - so far so good. However, when I now decide that I don't want to trigger any of the actions contained in the menu, and tap somewhere on the background to close it, it can happen that I tap on the rectangle in the background. If I do so, the tap on the rectangle directly triggers the action defined in onTapGesture.
However, the desired behavior would be that when the menu is open, I can tap anywhere outside the menu to close it without triggering any other element.
Any idea how I could achieve this? Thanks!
(Let me know in the comments if further clarification is needed.)

You can implement an .overlay which is tappable and appears when you tap on the menu.
Make it cover the whole screen, it gets ignored by the Menu.
When tapping on the menu icon you can set a propertie to true.
When tapping on the overlay or a menu item, set it back to false.
You can use place it in your root view and use a viewmodel with #Environment to access it from everywhere.
The only downside is, that you need to place isMenuOpen = false in every menu button.
Apple is using the unexpected behaviour itself, a.ex in the Wether app.
However, I still think it's a bug and filed a report. (FB10033181)
#State var isMenuOpen: Bool = false
var body: some View {
NavigationView{
NavigationLink{
ChildView()
} label: {
Text("Some NavigationLink")
.padding()
}
.toolbar{
ToolbarItem(placement: .navigationBarTrailing){
Menu{
Button{
isMenuOpen = false
} label: {
Text("Some Action")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.onTapGesture {
isMenuOpen = true
}
}
}
}
.overlay{
if isMenuOpen {
Color.white.opacity(0.001)
.ignoresSafeArea()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
isMenuOpen = false
}
}
}
}

It's not amazing, but you can manually track the menu's state with a #State var and set this to true in the .onTap for the Menu.
You can then apply .disabled(inMenu) to background elements as needed. But you need to ensure all exits out of the menu properly set the variable back to false. So that means a) any menu items' actions should set it back to false and b) taps outside the menu, incl. on areas that technically are "disabled" also need to switch it back to false.
There are a bunch of ways to achieve this, depending on your view hierarchy. The most aggressive approach (in terms of not missing a menu exit) might be to conditionally overlay a clear blocking view with an .onTap that sets inMenu back to false. This could however have Accessibility downsides. Optimally, of course, there would just be a way to directly bind to the menu's presentationMode or the treatment of surrounding taps could be configured on the Menu. In the meantime, the approach above has worked ok for me.

I think I have a solution, but it’s a hack… and it won’t work with the SwiftUI “App” lifecycle.
In your SceneDelegate, instead of creating a UIWindow use this HackedUIWindow subclass instead:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = HackedWindow(windowScene: windowScene) // <-- here!
window.rootViewController = UIHostingController(rootView: ContentView())
self.window = window
window.makeKeyAndVisible()
}
}
class HackedUIWindow: UIWindow {
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
if type(of: subview) == NSClassFromString("_UIContextMenuContainerView") {
if let rootView = self.rootViewController?.view {
rootView.isUserInteractionEnabled = false
}
}
}
override func willRemoveSubview(_ subview: UIView) {
super.willRemoveSubview(subview)
if type(of: subview) == NSClassFromString("_UIContextMenuContainerView") {
if let rootView = self.rootViewController?.view {
rootView.isUserInteractionEnabled = true
}
}
}
}
The subclass watches for subviews being added/removed, looking for one of type _UIContextMenuContainerView that’s used by context menus. When it sees one being added, it grabs the window’s root view and disables user interaction; when the context menu is removed, it re-enables user interaction.
This has worked in my testing but YMMV. It may also be wise to obfuscate the "_UIContextMenuContainerView" string so App Review doesn’t notice you referencing a private class.

You can have the behavior you want by using a Form or a List instead of a "plain view". All buttons will then be disabled by default when the menu is on screen, but they need to be buttons, and only one per cell, it won't work with a tapGesture because what you are actually doing is tapping on the cell, and SwiftUI is disabling TableView taps for you.
The key elements to achieve this are:
Use a Form or a List
Use an actual Button. In your example you use a Rectangle with a tapGesture.
I modified the code you provided and if you open the menu you can't hit the button:
struct SwiftUIView: View {
#State private var toggle: Bool = true
var body: some View {
VStack {
Spacer()
if toggle {
Text("on")
} else {
Text("off")
}
Spacer()
/// We add a `List` (this could go at whole screen level)
List {
/// We use a `Button` that has a `Rectangle`
/// rather than a tapGesture
Button {
toggle.toggle()
} label: {
Rectangle()
.frame(height: 200)
}
/// Important: Never use `buttonStyle` or the
/// default behavior for buttons will stop working
}
.listStyle(.plain)
.frame(height: 200)
Spacer()
Menu("Actions") {
Button("Duplicate", action: { toggle.toggle() })
Button("Rename", action: { toggle.toggle() })
Button("Delete", action: { toggle.toggle() })
}
Spacer()
}
}
}
Bonus:
Bonus: Don't use a buttonStyle. I lost so many hours of code because of this and I want to share it here too. In my app all buttons have a buttonStyle. It turns out that by using a style, you remove some of the behaviors you get by default (like the one we are discussing).
Instead of using a buttonStyle use an extension like this:
extension Button {
func withRedButtonStyle() -> some View {
self.foregroundColor(Color(UIColor.primary.excessiveRed))
.font(Font(MontserratFont.regular.fontWithSize(14)))
}
}
And add the withRedButtonStyle() at the end of the button.

In my case an alert was prevented from showing in a similar scenario, conflicting with the Menu as well as Datepicker. My workaround was using a slight delay with DispatchQueue.
Rectangle()
.frame(height: 200)
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05){
toggle.toggle()
}
}
The only real solution will happen when Apple fixes/refines SwiftUI regarding Menu (and Datepicker) behaviours.

Related

Menu Picker Sorting changes depending on location of view [duplicate]

How to set sort order for the below button items (ignore and pin) ? Its not showing in the exact sort order in the menu list. I think os itself is updating the sort order. is there any option to restrict the same?
struct ContentView: View {
var body: some View {
Menu {
Button(action: {
self.self.placeOrder()
}) {
HStack {
Text("Ignore")
.multilineTextAlignment(.leading)
Image("Menu_Ignore")
.renderingMode(.original)
}
}
Button(action: {
self.adjustOrder()
}) {
HStack {
Text("Pin")
.multilineTextAlignment(.leading)
Image("Menu_Pin")
.renderingMode(.original)
}
}
} label: {
Label("Options", systemImage: "paperplane")
}
}
func placeOrder() { }
func adjustOrder() { }}
New in Xcode 14 / iOS 16 / macOS 13 / ...
Now we have environment value to manage this:
var body: some View {
Menu {
// .. buttons here
}
.environment(\.menuOrder, .fixed) // << here !!
}
It feels like a force, but I solved it by getting the global y position of Menu with GeometryReader and setting the order at the top or bottom of the screen.
The position where you tapped the Menu (top or bottom) determines the order of the elements in the Menu, so I used that characteristic.
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
GeometryReader { geometry in
Menu("show menu") {
// get global position
let frame = geometry.frame(in: .global)
// top or bottom
let isTop = frame.midY <= UIScreen.main.bounds.height/2
if isTop {
Button("first button", action: {})
Button("second button", action: {})
}
else {
Button("second button", action: {})
Button("first button", action: {})
}
}
}
.padding(.vertical, 800)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Update
As mentioned by Asperi, it's possible in iOS 16 & macOS 13
https://developer.apple.com/documentation/swiftui/text/menuorder(_:)
Original answer for iOS 15 & macOS 12 and lower
Your code is totally fine and it works perfectly. The problem you have probably relates to the fact that the first menu item will always appear closest to the View that the user tapped on to trigger the Menu. ("Menu label" in your example).
So if the tapped object is low on the screen, then the system might show the menu above instead of below the tap. When it does this, it reverses the order.
This might be not clear with 2 Menu items (because if you reverse them it might look as a random change of the order) but if you add more you'll see that the order stays the same but the whole Menu might be shown either below the tap with the normal order or above the tap with the reversed order.

SwiftUI make ForEach List row properly clickable for edition in EditMode

Description
I have this following code:
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: someView()) {
someRowDisplayView()
} //: NavigationLink
} //: ForEach
} //: List
} //: NavigationView
Basically, it displays a dynamic list of class objects previously filled by user input. I have an "addToList()" function that allows users to add some elements (let's say name, email, whatever) and once the user confirms it adds the element to this list. Then I have a view that displays the list through a ForEach and then makes a NavigationLink of each element as indicated in the code example above.
Once clicked on an element of this list, the user should be properly redirected to a destination view as the NavigationLink implies. All this is working fine.
What I want
Here's where I face an issue: I want the user to be able to edit the content of a row of this list without having to delete the row and re-add another one.
I decided to use the EditMode() feature of SwiftUI. So this is what I came up with:
#State private var editMode = EditMode.inactive
[...]
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: someView()) {
someRowDisplayView()
} //: NavigationLink
} //: ForEach
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
} //: NavigationView
The EditMode is properly triggered when I click on the Edit button. But I noticed that the list row is not clickable in edit mode, which is fine because I do not want it to follow the NavigationLink while in edit mode.
Though what I want is that the user is either redirected to a view that allows editing of the tapped row, or better, that an edition sheet is presented to the user.
What I have tried
Since I couldn't tap the row in edition mode, I have tried several tricks but none of them concluded as I wanted. Here are my tries.
.simultaneousGesture
I tried to add a .simultaneousGesture modified to my NavigationLink and toggle a #State variable in order to display the edition sheet.
#State private var isShowingEdit: Bool = false
[...]
.simultaneousGesture(TapGesture().onEnded{
isShowingEdit = true
})
.sheet(isPresented: $isShowingEdit) {
EditionView()
}
This simply does not work. It seems that this .simultaneousGesture somehow breaks the NavigationLink, the tap succeeds like once out of five times.
It doesn't work even by adding a condition on the edit mode, like:
if (editMode == .active) {
isShowingEdit = true
}
In non-edition mode the NavigationLink is still bugged. But I have noticed that once in edition mode, it kind of does what I wanted.
Modifier condition extension on View
After the previous failure my first thought was that I needed the tap gesture to be triggered only in edit mode, so that in non-edit mode the view doesn't even know that it has a tap gesture.
I decided to add an extension to the View structure in order to define conditions to modifiers (I found this code sample somewhere on Stackoverflow):
extension View {
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
Then in my main code, instead of the previously tried .simultaneousGesture modifier, I added this to NavigationLink:
.if(editMode == .active) { view in
view.onTapGesture {
isShowingEdit = true
}
}
And it worked, I was able to display this edition view once a user taps on a row while in edition mode and the normal non-edition mode still worked as it properly triggered the NavigationLink.
Remaining issues
But something bothered me, it didn't feel like a natural or native behavior. Once tapped, the row didn't show any feedback like it shows in non-edition mode when following the NavigationLink: it doesn't highlight even for a very short time.
The tap gesture modifier simply executes the code I have asked without animation, feedback or anything.
I would like the user to know and see which row was tapped and I would like it to highlight just as it does when when tapping on the NavigationLink: simply make it look like the row was actually "tapped". I hope it makes sense.
I would also like the code to be triggered when the user taps on the whole row and not only the parts where the text is visible. Currently, tapping on an empty field of the row does nothing. It has to have text on it.
An even better solution would be something that prevents me from applying such conditional modifiers and extensions to the View structure as I surely prefer a more natural and better method if this is possible.
I'm new to Swift so maybe there is a lot easier or better solution and I'm willing to follow the best practices.
How could I manage to accomplish what I want in this situation?
Thank you for your help.
Additional information
I am currently using the .onDelete and .onMove implementations on the List alongside with the SwiftUI edition mode and I want to keep using them.
I am developing the app for minimum iOS 14, using Swift language and SwiftUI framework.
If you need more code samples or better explanations please feel free to ask and I will edit my question.
Minimal working example
Asked by Yrb.
import SwiftUI
import PlaygroundSupport
extension View {
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
struct SomeList: Identifiable {
let id: UUID = UUID()
var name: String
}
let someList: [SomeList] = [
SomeList(name: "row1"),
SomeList(name: "row2"),
SomeList(name: "row3")
]
struct ContentView: View {
#State private var editMode = EditMode.inactive
#State private var isShowingEditionSheet: Bool = false
var body: some View {
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: EmptyView()) {
Text(someElement.name)
} //: NavigationLink
.if(editMode == .active) { view in
view.onTapGesture {
isShowingEditionSheet = true
}
}
} //: ForEach
.onDelete { (indexSet) in }
.onMove { (source, destination) in }
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $isShowingEditionSheet) {
Text("Edition sheet")
}
} //: NavigationView
}
}
PlaygroundPage.current.setLiveView(ContentView())
As we discussed in the comments, your code does everything in your list except highlight the row when the disclosure chevron is tapped. To change the background color of a row, you use .listRowBackground(). To make just one row highlight, you need to make the row identifiable off of the id in your array in the ForEach. You then have an optional #State variable to hold the value of the row id, and set the row id in your .onTapGesture. Lastly, you use a DispatchQueue.main.asyncAfter() to reset the variable to nil after a certain time. This gives you a momentary highlight.
However, once you go this route, you have to manage the background highlighting for your NavigationLink as well. This brings its own complexity. You need to use the NavigationLink(destination:,isActive:,label:) initializer, create a binding with a setter and getter, and in the getter, run your highlight code as well.
struct ContentView: View {
#State private var editMode = EditMode.inactive
#State private var isShowingEditionSheet: Bool = false
#State private var isTapped = false
#State private var highlight: UUID?
var body: some View {
NavigationView {
List {
ForEach(someList) { someElement in
// You use the NavigationLink(destination:,isActive:,label:) initializer
// Then create your own binding for it
NavigationLink(destination: EmptyView(), isActive: Binding<Bool>(
get: { isTapped },
// in the set, you can run other code
set: {
isTapped = $0
// Set highlight to the row you just tapped
highlight = someElement.id
// Reset the row id to nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
highlight = nil
}
}
)) {
Text(someElement.name)
} //: NavigationLink
// identify your row
.id(someElement.id)
.if(editMode == .active) { view in
view.onTapGesture {
// Set highlight to the row you just tapped
highlight = someElement.id
// Reset the row id to nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
highlight = nil
}
isShowingEditionSheet = true
}
}
.listRowBackground(highlight == someElement.id ? Color(UIColor.systemGray5) : Color(UIColor.systemBackground))
} //: ForEach
.onDelete { (indexSet) in }
.onMove { (source, destination) in }
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $isShowingEditionSheet) {
Text("Edition sheet")
}
} //: NavigationView
}
}

Can't get removal of view to animate

I have a VStack where I have a title above some other views. The title is shown/hidden based on the value of an #Published variable from an environment object. Ideally, when the title should be hidden, I want it to transition by fading out and moving the views below it in the VStack up. However, what actually happens is it just disappears immediately and moves the rest of the views up without animation. How can I fix this?
struct MyView: View {
#EnvironmentObject var modelController: MyModelController
var body: some View {
VStack {
title()
//..other views here
}
}
#ViewBuilder
func title() -> some View {
if let currentPage = modelController.currentPage,
currentPage >= 6 {
EmptyView()
} else {
Text("Create Event")
}
}
}
You'll need to use the .transition() modifier to tell the system that you want to animate when the view appears or disappears. Additionally, I don't think you need to return EmptyView when you want to hide the title.
#ViewBuilder
func title() -> some View {
if modelController.currentPage ?? 0 < 6 {
Text("Create Event")
.transition(.opacity)
}
}
I've used the opacity transition but it's a very customizable modifier, and you can pass an asymmetric transition that does different animations on insert and removal. I would suggest googling it or looking at the documentation to learn more about it.
Your code snapshot is not testable, but try the following
VStack {
title()
//..other views here
}
.animation(.default, value: modelController.currentPage) // << here !!

Remove back button text from navigationbar in SwiftUI

I've recently started working in SwiftUI, came to the conclusion that working with navigation isn't really great yet. What I'm trying to achieve is the following. I finally managed to get rid of the translucent background without making the application crash, but now I ran into the next issue. How can I get rid of the "back" text inside the navbaritem?
I achieved the view above by setting the default appearance in the SceneDelegate.swift file like this.
let newNavAppearance = UINavigationBarAppearance()
newNavAppearance.configureWithTransparentBackground()
newNavAppearance.setBackIndicatorImage(UIImage(named: "backButton"), transitionMaskImage: UIImage(named: "backButton"))
newNavAppearance.titleTextAttributes = [
.font: UIFont(name: GTWalsheim.bold.name, size: 18)!,
.backgroundColor: UIColor.white
]
UINavigationBar.appearance().standardAppearance = newNavAppearance
One possible way that I could achieve this is by overriding the navigation bar items, however this has one downside (SwiftUI Custom Back Button Text for NavigationView) as the creator of this issue already said, the back gesture stops working after you override the navigation bar items. With that I'm also wondering how I could set the foregroundColor of the back button. It now has the default blue color, however I'd like to overwrite this with another color.
Piggy-backing on the solution #Pitchbloas offered, this method just involves setting the backButtonDisplayMode property to .minimal:
extension UINavigationController {
open override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
navigationBar.topItem?.backButtonDisplayMode = .minimal
}
}
It's actually really easy. The following solution is the fastest and cleanest i made.
Put this at the bottom of your SceneDelegate for example.
extension UINavigationController {
// Remove back button text
open override func viewWillLayoutSubviews() {
navigationBar.topItem?.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
}
}
This will remove the back button text from every NavigationView (UINavigationController) in your app.
I have found a straightforward approach to remove the back button text using SwiftUI only, and keeping the original chevron.
A drag gesture is added to mimic the classic navigation back button
when user wants to go back by swiping right. Following this, an extension of View is created to create a SwiftUI like modifier.
This is how to use it in code:
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack {
// Your main view code here with a ZStack to have the
// gesture on all the view.
}
.navigationBarBackButtonTitleHidden()
}
}
This is how to create the navigationBarBackButtonTitleHidden() modifier:
import SwiftUI
extension View {
func navigationBarBackButtonTitleHidden() -> some View {
self.modifier(NavigationBarBackButtonTitleHiddenModifier())
}
}
struct NavigationBarBackButtonTitleHiddenModifier: ViewModifier {
#Environment(\.dismiss) var dismiss
#ViewBuilder #MainActor func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.navigationBarItems(
leading: Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.foregroundColor(.blue)
.imageScale(.large) })
.contentShape(Rectangle()) // Start of the gesture to dismiss the navigation
.gesture(
DragGesture(coordinateSpace: .local)
.onEnded { value in
if value.translation.width > .zero
&& value.translation.height > -30
&& value.translation.height < 30 {
dismiss()
}
}
)
}
}
Standard Back button title is taken from navigation bar title of previous screen.
It is possible the following approach to get needed effect:
struct TestBackButtonTitle: View {
#State private var hasTitle = true
var body: some View {
NavigationView {
NavigationLink("Go", destination:
Text("Details")
.onAppear {
self.hasTitle = false
}
.onDisappear {
self.hasTitle = true
}
)
.navigationBarTitle(self.hasTitle ? "Master" : "")
}
}
}
So I actually ended up with the following solution that actually works. I am overwriting the navigation bar items like so
.navigationBarItems(leading:
Image("backButton")
.foregroundColor(.blue)
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
)
The only issue with this was that the back gesture wasn't working so that was solved by actually extending the UINavigationController
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
Now it's looking exactly the way I want it, the solution is kinda hacky... but it works for now, hopefully SwiftUI will mature a little bit so this can be done easier.
Using the Introspect framework, you can easily gain access to the underlying navigation item and set the backButtonDisplayMode to minimal.
Here’s how you might use that in the view that was pushed
var body: some View {
Text("Your body here")
.introspectNavigationController { navController in
navController.navigationBar.topItem?.backButtonDisplayMode = .minimal
}
}
If you want to:
Do it globally
Keep the standard back button (along with custom behaviours like an ability to navigate a few screens back on a long press)
Avoid introducing any third party frameworks
You can do it by setting the back button text color to Clear Color via appearance:
let navigationBarAppearance = UINavigationBarAppearance()
let backButtonAppearance = UIBarButtonItemAppearance(style: .plain)
backButtonAppearance.focused.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.disabled.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.highlighted.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear]
navigationBarAppearance.backButtonAppearance = backButtonAppearance
//Not sure you'll need both of these, but feel free to adjust to your needs.
UINavigationBar.appearance().standardAppearance = navigationBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
You can do it once when the app starts and forget about it.
A potential downside (depending on your preferences) is that the transition to the clear color is animated as the title of the current window slides to the left as you move to a different one.
You can also experiment with different text attributes.
Works on iOS 16
Solutions above didn't work for me. I wanted to make changes specific to view without any global (appearance or extension) and with minimal boilerplate code.
Since you can update NavigationItem inside the init of the View. You can solve this in 2 steps:
Get visible View Controller.
// Get Visible ViewController
extension UIApplication {
static var visibleVC: UIViewController? {
var currentVC = UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
while let presentedVC = currentVC?.presentedViewController {
if let navVC = (presentedVC as? UINavigationController)?.viewControllers.last {
currentVC = navVC
} else if let tabVC = (presentedVC as? UITabBarController)?.selectedViewController {
currentVC = tabVC
} else {
currentVC = presentedVC
}
}
return currentVC
}
}
Update NavigationItem inside init of the View.
struct YourView: View {
init(hideBackLabel: Bool = true) {
if hideBackLabel {
// iOS 14+
UIApplication.visibleVC?.navigationItem.backButtonDisplayMode = .minimal
// iOS 13-
let button = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
UIApplication.visibleVC?.navigationItem.backBarButtonItem = button
}
}
}
custom navigationBarItems and self.presentationMode.wrappedValue.dismiss() worked but you are not allow to perform swiping back
You can either add the following code to make the swipe back again
//perform gesture go back
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
but the problem is, sometimes it will make your app crashed when you swipe half the screen and then cancel.
I would suggest the other way to remove the "Back" text.
Adding the isActive state to monitor whether the current screen is active or not. :)
struct ContentView: View {
#State var isActive = false
var body: some View {
NavigationView() {
NavigationLink(
"Next",
destination: Text("Second Page").navigationBarTitle("Second"),
isActive: $isActive
)
.navigationBarTitle(!isActive ? "Title" : "", displayMode: .inline)
}
}
}
I am accomplishing this by changing the title of the master screen before pushing the detail screen and then setting it back when it re-appears. The only caveat is when you go back to the master screen the title's re-appearance is a little noticeable.
Summary:
on master view add state var (e.g. isDetailShowing) to store if detail screen is showing or not
on master view use the navigationTitle modifier to set the title based on the current value of isDetailShowing
on master view use onAppear modifier to set the value of isDetailShowing to false
on the NavigationLink in master screen use the simultaneousGesture modifier to set the isDetailShowing to true
struct MasterView: View {
#State var isDetailShowing = false
var body: some View {
VStack {
Spacer()
.frame(height: 20)
Text("Master Screen")
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
.frame(height: 20)
NavigationLink(destination: DetailView()) {
Text("Go to detail screen")
}
.simultaneousGesture(TapGesture().onEnded() {
isDetailShowing = true
})
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(isDetailShowing ? "" : "Master Screen Title")
.onAppear() {
isDetailShowing = false
}
}
}
struct DetailView: View {
var body: some View {
Text("This is the detail screen")
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Detail Screen Title")
}
}
you can use .toolbarRole(.editor)
Why not use Custom BackButton with Default Back Button Hidden
You could use Any Design You Prefer !
Works on iOS 16
First View
struct ContentView: View {
var body: some View {
NavigationView {
VStack(){
Spacer()
NavigationLink(destination: View2()) {
Text("Navigate")
.font(.title)
}
Spacer()
}
}
}
}
Second View
struct View2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
ZStack{
VStack{
HStack(alignment:.center){
//Any Design You Like
Image(systemName: "chevron.left")
.font(.title)
.foregroundColor(.blue)
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
.padding()
Spacer()
}
Spacer()
}
}
}
.navigationBarBackButtonHidden(true)
}
}

How to disable Flashing when tapping PresentationLink (SwiftUI)?

How can I disable the highlighted color when a button is tapped?
Now when I tap it, it gets gray and the action gets called, but I want to disable it.
Is it possible at the moment?
PresentationLink(destination: NextView()) {
....
}
PresentationView does not seem to have a way of styling the button, and I doubt it'll ever will. However, there are other methods to present a view. Below you have an example that will avoid the effect. It is a little more verbose, but it will serve your purpose.
As of beta3, modals seem to have a bug, and the onDismiss method is never called. So it is hard to reset the isPresented variable properly. In the meantime, I use a workaround for that. Check this answer for that: https://stackoverflow.com/a/56939555/7786555
struct ContentView : View {
#State var isPresented = false
var body: some View {
VStack(spacing: 30) {
// Option #1, with blink
PresentationLink(destination: NextView(), label: {
Text("Click to show")
})
// Option #2, without blink
Text("Click to show").color(.blue).tapAction { self.isPresented = true }
.presentation(isPresented ? Modal(NextView()) : nil)
}
}
}
struct NextView: View {
var body: some View {
Text("aloha!")
}
}

Resources