SwiftUI - navigationBarBackButtonHidden - swipe back gesture? - swiftui

if I set a custom Back Button (which everyone wants, hiding the ugly text ;-) ) and using .navigationBarBackButtonHidden, the standard Swipe Back gesture on the navigation controller does not work. Is there a way to get this back and having a custom back button?
For Example:
NavigationView {
NavigationLink(destination: DummyViewer())
{
Text("Go to next view"
}
}
struct DummyViewer: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
Text("Hello, World!").navigationBarBackButtonHidden(true)
.navigationBarItems(leading:
Button(action: { self.presentationMode.wrappedValue.dismiss()}) {
Text("Custom go back")
}
)
}
}
If I do so, I cannot swipe back to the previous view, seems the gesture is then disabled... How to get it back?
BR
Steffen

Nothing I found about creating a custom NavigationView worked but I found that by extending UINavigationController I was able to have a custom back button and the swipe back gesture.
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}

I would like to integrate the answer given by Nick Bellucci to make the code also works in other circumstances, e.g. when the child view of the NavigationView is a ScrollView, or a View that is listening for Drag gestures.
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
// To make it works also with ScrollView
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}

I've just created a hack which will not animate view but it works
extension View {
func onBackSwipe(perform action: #escaping () -> Void) -> some View {
gesture(
DragGesture()
.onEnded({ value in
if value.startLocation.x < 50 && value.translation.width > 80 {
action()
}
})
)
}
}
Usage
struct TestView: View {
#Environment (\.presentationMode) var mode
var body: some View {
VStack {
Color.red
}
.onBackSwipe {
mode.wrappedValue.dismiss()
}
}
}

You can set the title to an empty string. So back bar button title will be empty:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: Text("Here you are")) {
Text("Next").navigationBarTitle("")
}
}
}
}
You can set the title onAppear or onDisappear if you need to.

If it's still actual, here I answered, how to set custom back button and save swipe back gesture.

Related

How to add a custom tap handler on a custom SwiftUI View?

List {
ItemView(item: item)
.myCustomTapHandler {
print("ItemView was tapped, triggered from List!")
}
}
}
struct ItemView: View {
var body: some View {
VStack {
Button(action: {
// this should fire myCustomTapHandler
}) {
Text("Hello world")
}
}
}
I have a custom ItemView with a simple button. I want to re create the same trailing closure syntax as .onTapGesture, with only triggering when you tap the Button. This will be named .myCustomTapHandler How to do this in SwiftUI?
What you are describing (The dot) is a ViewModifier
struct MyItemListView: View {
var body: some View {
List {
//Using a ViewModifier you make the whole View tappable not just the button
MyItemView(item: 2)
.myCustomTapHandler{
print("ItemView has custom modifier that makes the whole ItemView tappable")
}
}
}
}
struct MyCustomTapHandler: ViewModifier {
var myCustomTapHandler: () -> Void
func body(content: Content) -> some View {
content
//Add the onTap to the whole View
.onTapGesture {
myCustomTapHandler()
}
}
}
extension View {
func myCustomTapHandler(myCustomTapHandler: #escaping () -> Void) -> some View {
modifier(MyCustomTapHandler(myCustomTapHandler: myCustomTapHandler))
}
}
The ViewModifier affects the entire View not just the Button.
But, unless you are doing something else it is just an onTapGesture.
This is likely not the best solution because with the Button in the ItemView you will have inconsistent results.
Sometimes the Button will get the tap and sometimes the ViewModifier will get the tap and given that the View is in a List it will likely make the whole tapping confusing because the List has properties that make the whole row tappable anyway vs just the Text of the `Button
If you want the Button to perform an action that is defined in the ListView you can pass it as a parameter.
This will likely give you the best results.
struct MyItemListView: View {
var body: some View {
VStack {
//This passes the custom action to the button
MyItemView(item: 1){
print("Button needs to be tapped to trigger this")
}
}
}
}
struct MyItemView: View {
let item: Int
var myCustomTapHandler: () -> Void
var body: some View {
VStack {
Button(action: {
myCustomTapHandler()
}) {
Text("Hello world")
}
}
}
}
You can add like this.
struct ItemView: View {
private var action: (() -> Void)? = nil
var body: some View {
VStack {
Button(action: {
action?()
}) {
Text("Hello world")
}
}
}
func myCustomTapHandler(onAction: #escaping () -> Void) -> Self {
var view = self
view.action = onAction
return view
}
}
Usage
struct ContentView: View {
var body: some View {
ItemView()
.myCustomTapHandler {
print("Hello word")
}
}
}
Another way is...
I don't think with dot property you will get action. You need closure inside the ItemView.
Like this
struct ItemView: View {
var action: () -> Void
var body: some View {
VStack {
Button(action: action) {
Text("Hello world")
}
}
}
}
Usage
List {
ItemView {
// Here you will get action
print("ItemView was tapped, triggered from List!")
}
}

SwiftUI changing text title of navigationBar Button at the current view to "Back" instead of inheriting the text from previous view title

I have a swiftUI view with navigation links that when i click will navigate to another view . The issue is the second view navigationBa button title still has the title of the previous view instead of a logical back title . How i can have the title as Back with changing the title as "Back" in the first view .
First view navigationBar code: The second view just shows the news website in a WebView.
.navigationBarTitle("Breaking News")
The way i tried is changing the title to this:
.navigationBarTitle("Back")
This will work but the title of the first view changes to "Back" Instead of "Breaking News"
Is there any way i can fix this
An alternative approach is to hide the back button and create your own back button like this:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination : SomeView()) {
Text("Open")
}
.navigationBarTitle("Breaking News")
}
}
}
// Use navigationBarItems for creating your own bar item.
struct SomeView : View {
#Environment(\.presentationMode) var mode
var body : some View {
Text("Hello, World!")
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading:
Button(action : {
self.mode.wrappedValue.dismiss()
}){
Text("\(Image(systemName: "chevron.left"))Back")
})
}
}
The accepted answer looks glitchy, it removes a lot of standard behaviour and animations, including long press gesture.
Consider using custom backBarButtonTitle modifier:
struct FirstView: View {
var body: some View {
Text("First view")
.backBarButtonTitle("Back")
}
}
It sets the backButtonTitle property to the topmost UINavigationItem in stack. Be sure to use this modifier on the view, whose title you want to change, see documentation.
Here is the implementation of the backBarButtonTitle:
import SwiftUI
import UIKit
extension View {
func backBarButtonTitle(_ title: String) -> some View {
modifier(BackButtonModifier(title: title))
}
}
// MARK: - BackButtonModifier
struct BackButtonModifier: ViewModifier {
let title: String
func body(content: Content) -> some View {
content.background(BackButtonTitleView(title: title))
}
}
// MARK: - BackButtonTitleView
private struct BackButtonTitleView: UIViewRepresentable {
let title: String
func makeUIView(context _: Context) -> BackButtonTitleUIView {
BackButtonTitleUIView(title: title)
}
func updateUIView(_: BackButtonTitleUIView, context _: Context) {}
}
// MARK: - BackButtonTitleUIView
private final class BackButtonTitleUIView: UIView {
// MARK: Lifecycle
init(title: String) {
self.title = title
super.init(frame: .zero)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
override func layoutSubviews() {
super.layoutSubviews()
if didConfigureTitle {
return
}
let topNavigationItem = searchNavigationController(currentResponder: self)?
.topViewController?
.navigationItem
if let topNavigationItem {
topNavigationItem.backButtonTitle = title
didConfigureTitle = true
}
}
// MARK: Private
private let title: String
private var didConfigureTitle = false
private func searchNavigationController(currentResponder: UIResponder) -> UINavigationController? {
if let navigationController = currentResponder as? UINavigationController {
return navigationController
} else if let nextResponder = currentResponder.next {
return searchNavigationController(currentResponder: nextResponder)
} else {
return nil
}
}
}

SwiftUI NavigationLink double click on List MacOS

Can anyone think how to call an action when double clicking a NavigationLink in a List in MacOS? I've tried adding onTapGesture(count:2) but it does not have the desired effect and overrides the ability of the link to be selected reliably.
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item)) {
ItemRow(itemRow: item) //<-my row view
}.buttonStyle(PlainButtonStyle())
.simultaneousGesture(TapGesture(count:2)
.onEnded {
print("double tap")
})
}
}
}
}
EDIT:
I've set up a tag/selection in the NavigationLink and can now double or single click the content of the row. The only trouble is, although the itemDetail view is shown, the "active" state with the accent does not appear on the link. Is there a way to either set the active state (highlighted state) or extend the NavigationLink functionality to accept double tap as well as a single?
#State var selection:String?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item), tag: item.id, selection: self.$selection) {
ItemRow(itemRow: item) //<-my row view
}.onTapGesture(count:2) { //<- Needed to be first!
print("doubletap")
}.onTapGesture(count:1) {
self.selection = item.id
}
}
}
}
}
Here's another solution that seems to work the best for me. It's a modifier that adds an NSView which does the actual handling. Works in List even with selection:
extension View {
/// Adds a double click handler this view (macOS only)
///
/// Example
/// ```
/// Text("Hello")
/// .onDoubleClick { print("Double click detected") }
/// ```
/// - Parameters:
/// - handler: Block invoked when a double click is detected
func onDoubleClick(handler: #escaping () -> Void) -> some View {
modifier(DoubleClickHandler(handler: handler))
}
}
struct DoubleClickHandler: ViewModifier {
let handler: () -> Void
func body(content: Content) -> some View {
content.background {
DoubleClickListeningViewRepresentable(handler: handler)
}
}
}
struct DoubleClickListeningViewRepresentable: NSViewRepresentable {
let handler: () -> Void
func makeNSView(context: Context) -> DoubleClickListeningView {
DoubleClickListeningView(handler: handler)
}
func updateNSView(_ nsView: DoubleClickListeningView, context: Context) {}
}
class DoubleClickListeningView: NSView {
let handler: () -> Void
init(handler: #escaping () -> Void) {
self.handler = handler
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if event.clickCount == 2 {
handler()
}
}
}
https://gist.github.com/joelekstrom/91dad79ebdba409556dce663d28e8297
I've tried all these solutions but the main issue is using gesture or simultaneousGesture overrides the default single tap gesture on the List view which selects the item in the list. As such, here's a simple method I thought of to retain the default single tap gesture (select row) and handle a double tap separately.
struct ContentView: View {
#State private var monitor: Any? = nil
#State private var hovering = false
#State private var selection = Set<String>()
let fruits = ["apple", "banana", "plum", "grape"]
var body: some View {
List(fruits, id: \.self, selection: $selection) { fruit in
VStack {
Text(fruit)
.frame(maxWidth: .infinity, alignment: .leading)
.clipShape(Rectangle()) // Allows the hitbox to be the entire word not the if you perfectly press the text
}
.onHover {
hovering = $0
}
}
.onAppear {
monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
if $0.clickCount == 2 && hovering { // Checks if mouse is actually hovering over the button or element
print("Double Tap!") // Run action
}
return $0
}
}
.onDisappear {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
}
}
}
}
This works if you just need to single tap to select and item, but only do something if the user double taps. If you want to handle a single tap and a double tap, there still remains the problem of single tap running when its a double tap. A potential work around would be to capture and delay the single tap action by a few hundred ms and cancel it if it was a double tap action
Use simultaneous gesture, like below (tested with Xcode 11.4 / macOS 10.15.5)
NavigationLink(destination: Text("View One")) {
Text("ONE")
}
.buttonStyle(PlainButtonStyle()) // << required !!
.simultaneousGesture(TapGesture(count: 2)
.onEnded { print(">> double tap")})
or .highPriorityGesture(... if you need double-tap has higher priority
Looking for a similar solution I tried #asperi answer, but had the same issue with tappable areas as the original poster. After trying many variations the following is working for me:
#State var selection: String?
...
NavigationLink(destination: HistoryListView(branch: string), tag: string, selection: self.$selection) {
Text(string)
.gesture(
TapGesture(count:1)
.onEnded({
print("Tap Single")
selection = string
})
)
.highPriorityGesture(
TapGesture(count:2)
.onEnded({
print("Tap Double")
})
)
}

SwiftUI: Hide Statusbar on NavigationLink destination

I have a master detail structure with a list on master and a detail page where I want to present a webpage fullscreen, so no navigation bar and no status bar. The user can navigate back by a gesture (internal app).
I'm stuggeling hiding the statusbar with
.statusBar(hidden: true)
This works on master page, but not on detail page.
Hiding the navigation bar works fine with my ViewModifier
public struct NavigationAndStatusBarHider: ViewModifier {
#State var isHidden: Bool = false
public func body(content: Content) -> some View {
content
.navigationBarTitle("")
.navigationBarHidden(isHidden)
.statusBar(hidden: isHidden)
.onAppear {self.isHidden = true}
}
}
extension View {
public func hideNavigationAndStatusBar() -> some View {
modifier(NavigationAndStatusBarHider())
}
}
Any idea?
I've been trying this for a couple of hours out of curiosity. At last, I've got it working.
The trick is to hide the status bar in the Main view, whenever the user navigates to the detail view. Here's the code tested in iPhone 11 Pro Max - 13.3 and Xcode version 11.3.1. Hope you like it ;). Happy coding.
import SwiftUI
import UIKit
import WebKit
struct ContentView: View {
var urls: [String] = ["https://www.stackoverflow.com", "https://www.yahoo.com"]
#State private var hideStatusBar = false
var body: some View {
NavigationView {
List {
ForEach(urls, id: \.self) { url in
VStack {
NavigationLink(destination: DetailView(url: url)) {
Text(url)
}
.onDisappear() {
self.hideStatusBar = true
}
.onAppear() {
self.hideStatusBar = false
}
}
}
}
.navigationBarTitle("Main")
}
.statusBar(hidden: hideStatusBar)
}
}
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var url: String = ""
var body: some View {
VStack {
Webview(url: url)
Button("Tap to go back.") {
self.presentationMode.wrappedValue.dismiss()
}
Spacer()
}
.hideNavigationAndStatusBar()
}
}
public struct NavigationAndStatusBarHider: ViewModifier {
#State var isHidden: Bool = false
public func body(content: Content) -> some View {
content
.navigationBarTitle("")
.navigationBarHidden(isHidden)
.statusBar(hidden: isHidden)
.onAppear {self.isHidden = true}
}
}
struct Webview: UIViewRepresentable {
var url: String
typealias UIViewType = WKWebView
func makeUIView(context: UIViewRepresentableContext<Webview>) -> WKWebView {
let wkWebView = WKWebView()
guard let url = URL(string: self.url) else {
return wkWebView
}
let request = URLRequest(url: url)
wkWebView.load(request)
return wkWebView
}
func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<Webview>) {
}
}
extension View {
public func hideNavigationAndStatusBar() -> some View {
modifier(NavigationAndStatusBarHider())
}
}
Well, your observed behaviour is because status bar hiding does not work being called from inside NavigationView, but works outside. Tested with Xcode 11.2 and(!) Xcode 11.4beta3.
Please see below my findings.
Case1 Case2
Case1: Inside any stack container
struct TestNavigationWithStatusBar: View {
var body: some View {
VStack {
Text("Hello, World!")
.statusBar(hidden: true)
}
}
}
Case2: Inside NavigationView
struct TestNavigationWithStatusBar: View {
var body: some View {
NavigationView {
Text("Hello, World!")
.statusBar(hidden: true)
}
}
}
The solution (fix/workaround) to use .statusBar(hidden:) outside of navigation view. Thus you should update your modifier correspondingly (or rethink design to separate it).
struct TestNavigationWithStatusBar: View {
var body: some View {
NavigationView {
Text("Hello, World!")
}
.statusBar(hidden: true)
}
}
Solution for Xcode 12.5 and IOS 14.6:
Add the following to your Info.plist:
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
Add UIApplication.shared.isStatusBarHidden = false the following to your AppDelegate.swift:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
UIApplication.shared.isStatusBarHidden = false // -> This
return true
}
You can then hide and bring back the status bar anywhere in the app with the UIApplication.shared.isStatusBarHidden modifier.
Example:
struct Example: View {
// I'm checking the safe area below the viewport
// to be able to detect iPhone models without a notch.
#State private var bottomSafeArea = UIApplication.shared.windows.first?.safeAreaInsets.bottom
var body: some View {
Button {
if bottomSafeArea == 0 {
UIApplication.shared.isStatusBarHidden = true // or use .toggle()
}
} label: {
Text("Click here for hide status bar!")
.font(.title2)
}
}
}
I have a NavigationView that contains a view that presents a fullScreenModal. I wanted the status bar visible for the NavigationView, but hidden for the fullScreenModal. I tried multiple approaches but couldn't get anything working (it seems lots of people are finding bugs with the status bar and NavigationView on iOS14).
I've settled on a solution which is hacky but seems to work and should do the job until the bugs are fixed.
Add the following to your Info.plist:
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
And then add the following in your view's init when necessary (changing true/false as required):
UIApplication.shared.isStatusBarHidden = false
For example:
struct ContentView: View {
init() {
UIApplication.shared.isStatusBarHidden = true
}
var body: some View {
Text("Hello, world!")
}
}
It'll give you a deprecated warning, but I'm hoping this is a temporary fix.

Disable swipe-back for a NavigationLink SwiftUI

How can I disable the swipe-back gesture in SwiftUI? The child view should only be dismissed with a back-button.
By hiding the back-button in the navigation bar, the swipe-back gesture is disabled. You can set a custom back-button with .navigationBarItems()
struct ContentView: View {
var body: some View {
NavigationView{
List{
NavigationLink(destination: Text("You can swipe back")){
Text("Child 1")
}
NavigationLink(destination: ChildView()){
Text("Child 2")
}
}
}
}
}
struct ChildView: View{
#Environment(\.presentationMode) var presentationMode
var body:some View{
Text("You cannot swipe back")
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: Button("Back"){self.presentationMode.wrappedValue.dismiss()})
}
}
I use Introspect library then I just do:
import SwiftUI
import Introspect
struct ContentView: View {
var body: some View {
Text("A view that cannot be swiped back")
.introspectNavigationController { navigationController in
navigationController.interactivePopGestureRecognizer?.isEnabled = false
}
}
}
Only complete removal of the gesture recognizer worked for me.
I wrapped it up into a single modifier (to be added to the detail view).
struct ContentView: View {
var body: some View {
VStack {
...
)
.disableSwipeBack()
}
}
DisableSwipeBack.swift
import Foundation
import SwiftUI
extension View {
func disableSwipeBack() -> some View {
self.background(
DisableSwipeBackView()
)
}
}
struct DisableSwipeBackView: UIViewControllerRepresentable {
typealias UIViewControllerType = DisableSwipeBackViewController
func makeUIViewController(context: Context) -> UIViewControllerType {
UIViewControllerType()
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}
class DisableSwipeBackViewController: UIViewController {
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent?.parent,
let navigationController = parent.navigationController,
let interactivePopGestureRecognizer = navigationController.interactivePopGestureRecognizer {
navigationController.view.removeGestureRecognizer(interactivePopGestureRecognizer)
}
}
}
You can resolve the navigation controller without third party by using a UIViewControllerRepresentable in the SwiftUI hierarchy, then access the parent of its parent.
Adding this extension worked for me (disables swipe back everywhere, and another way of disabling the gesture recognizer):
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
}
This answer shows how to configure your navigation controller in SwiftUI (In short, use UIViewControllerRepresentable to gain access to the UINavigationController). And this answer shows how to disable the swipe gesture. Combining them, we can do something like:
Text("Hello")
.background(NavigationConfigurator { nc in
nc.interactivePopGestureRecognizer?.isEnabled = false
})
This way you can continue to use the built in back button functionality.
Setting navigationBarBackButtonHidden to true will lose the beautiful animation when you have set the navigationTitle.
So I tried another answer
navigationController.interactivePopGestureRecognizer?.isEnabled = false
But It's not working for me.
After trying the following code works fine
NavigationLink(destination: CustomView()).introspectNavigationController {navController in
navController.view.gestureRecognizers = []
}
preview
The following more replicates the existing iOS chevron image.
For the accepted answer.
That is replace the "back" with image chevron.
.navigationBarItems(leading: Button("Back"){self.presentationMode.wrappedValue.dismiss()})
With
Button(action: {self.presentationMode.wrappedValue.dismiss()}){Image(systemName: "chevron.left").foregroundColor(Color.blue).font(Font.system(size:23, design: .serif)).padding(.leading,-6)}