How to disable `Back` gesture in `NavigationView`? - ios

I need to disallow the Back swipe gesture in a view that has been "pushed" in a SwiftUI NavigationView.
I am using the navigationBarBackButtonHidden(true) view modifier from the "pushed" view, which obviously hides the standard Back button, and partially solves for the requirement.
But users cans still swipe from left to right (going back), which I don't want to allow.
I have tried using interactiveDismissDisabled, but this only disables swiping down to dismiss, not "back".
Any suggestions welcome.
[UPDATE] I tried creating a new app with Xcode 14.2, and the navigationBarBackButtonHidden(true) worked as expected:
No back button
Back swipe gesture disabled
But when I modified the main class of my existing app - with the EXACT SAME CODE as the new test app, it still allowed the back swipe gesture. Here's the code:
import SwiftUI
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
MyView()
}
}
}
struct MyView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink("Next page") {
Text("Page 2")
.navigationBarBackButtonHidden(true)
}
}
}
}
}
At this point, I'm fairly confused. Recreating my existing project, that was created with Xcode 13, will be a substantial task, so I'm trying to figure out what's different. I'm assuming that there is some build setting or configuration option that is somehow influencing this behavior.
Again, any suggestions welcome

For some cases, it may be necessary to remove the interactivePopGestureRecognizer from the UINavigationController. The DisableSwipeBack.swift example demonstrates this here: Disable swipe-back for a NavigationLink SwiftUI

It turns out that we had the following code in our app that was thought to be unused, but really wasn't, since it was extending UINavigationController.
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
Uggh.

Related

Working around a SwifUI layout bug involving UIViewControllerRepresentable

I have encountered a bug whenever UIViewControllerRepresentable is used within a SwiftUI view. I've filed a report to Feedback Assistant, but need to work around it to release my app.
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geo in
VStack {
Text("geo.size.height: \(geo.size.height), geo.safeAreaInsets.top: \(geo.safeAreaInsets.top)")
.padding()
TestGeo()
}
}
}
}
struct TestGeo: UIViewControllerRepresentable, Equatable {
func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewController { return UIViewController() }
func updateUIViewController(_ viewController: UIViewController, context: UIViewControllerRepresentableContext<Self>) {}
}
Note, the GeometryReader is not required for the bug to manifest but it might shed light on what is happening.
Steps to reproduce:
Run code on a notchless iPhone like the iPhone 8 Plus simulator in portrait.
Rotate simulator from portrait to landscape.
Rotate simulator from landscape to portrait.
The text reads:
geo.size.height: 736.0, geo.safeAreaInsets.top: 0.0
but should read:
geo.size.height: 716.0, geo.safeAreaInsets.top: 20.0
Also, the text position is moved 20 points too high.
An interesting way I've found of manually correcting the layout is to start to pull down the Notification Center, the layout and safe area heights will correct themselves.
Any ideas on how to make the layout correct itself?
Wrapping in a navigation controller seems to do the trick, just disable all unwanted UI effects such as the navigation bar:
func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewController {
let navigationController = UINavigationController(rootViewController: UIViewController())
navigationController.isNavigationBarHidden = true
return navigationController
}

How to dynamically hide the status bar and the home indicator in SwiftUI?

I'm working on a fractal clock app that displays animated fractals based on clock hands in its main view. I want users of my app to be able to enter a fullscreen mode where all unnecessary UI is temporarily hidden and only the animation remains visible. The behavior I'm looking for is similar to Apple's Photos app where one can tap on the currently displayed image so that the navigation bar, the bottom bar, the status bar and the home indicator fade out until the image is tapped again.
Hiding the navigation bar and the status bar was as easy as finding the right view modifiers to pass the hiding condition to. But as far as I know it is currently not possible in SwiftUI to hide the home indicator without bringing in UIKit.
On Stack Overflow I found this solution by Casper Zandbergen for conditionally hiding the home indicator and adopted it for my project.
It works but sadly in comes with an unacceptable side effect: The main view now no longer extends under the status bar and the home indicator which has two implications:
When hiding the status bar with the relevant SwiftUI modifier the space for the main view grows by the height of the hidden status bar interrupting the display of the fractal animation.
In place of the hidden home indicator always remains a black bottom bar preventing the fullscreen presentation of the main view.
I hope somebody with decent UIKit experience can help me with this. Please keep in mind that I'm a beginner in SwiftUI and that I have basically no prior experience with UIKit. Thanks in advance!
import SwiftUI
struct ContentView: View {
#StateObject var settings = Settings()
#State private var showSettings = false
#State private var hideUI = false
var body: some View {
NavigationView {
GeometryReader { proxy in
let radius = 0.5 * min(proxy.size.width, proxy.size.height) - 20
FractalClockView(settings: settings, clockRadius: radius)
}
.ignoresSafeArea(.all)
.toolbar {
Button(
action: { showSettings.toggle() },
label: { Label("Settings", systemImage: "slider.horizontal.3") }
)
.popover(isPresented: $showSettings) { SettingsView(settings: settings) }
}
.navigationBarTitleDisplayMode(.inline)
.onTapGesture {
withAnimation { hideUI.toggle() }
}
.navigationBarHidden(hideUI)
.statusBar(hidden: hideUI)
.prefersHomeIndicatorAutoHidden(hideUI) // Code by Amzd
}
.navigationViewStyle(.stack)
}
}
I was able to solve the problem with the SwiftUI view not extending beyond the safe area insets for the status bar and the home indicator by completely switching to a storyboard based project template and embedding my views through a custom UIHostingController as described in this solution by Casper Zandbergen.
Before I was re-integrating the hosting controller into the SwiftUI view hierarchy by wrapping it with a UIViewRepresentable instance, which must have caused the complications in handling the safe area.
By managing the whole app through the custom UIHostingController subclass it was even easier to get the hiding of the home indicator working. As much as I love SwiftUI I had to realize that, with its current limitations, UIKit was the better option here.
Final code (optimized version of the solution linked above):
ViewController.swift
import SwiftUI
import UIKit
struct HideUIPreferenceKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue() || value
}
}
extension View {
func userInterfaceHidden(_ value: Bool) -> some View {
preference(key: HideUIPreferenceKey.self, value: value)
}
}
class ViewController: UIHostingController<AnyView> {
init() {
weak var vc: ViewController? = nil
super.init(
rootView: AnyView(
ContentView()
.onPreferenceChange(HideUIPreferenceKey.self) {
vc?.userInterfaceHidden = $0
}
)
)
vc = self
}
#objc required dynamic init?(coder: NSCoder) {
weak var vc: ViewController? = nil
super.init(
coder: coder,
rootView: AnyView(
ContentView()
.onPreferenceChange(HideUIPreferenceKey.self) {
vc?.userInterfaceHidden = $0
}
)
)
vc = self
}
private var userInterfaceHidden = false {
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
}
override var prefersStatusBarHidden: Bool {
userInterfaceHidden
}
override var prefersHomeIndicatorAutoHidden: Bool {
userInterfaceHidden
}
}

SwiftUI animation and subsequent reverse animation to original state

I'm using SwiftUI and I want to animate a view as soon as it appears (the explicit type of animation does not matter) for demo purposes in my app.
Let's say I just want to scale up my view and then scale it down to its original size again, I need to be able to animate the view to a new state and back to the original state right afterward.
Here's the sample code of what I've tried so far:
import SwiftUI
import Combine
struct ContentView: View {
#State private var shouldAnimate = false
private var scalingFactor: CGFloat = 2
var body: some View {
Text("hello world")
.scaleEffect(self.shouldAnimate ? self.scalingFactor : 1)
.onAppear {
let animation = Animation.spring().repeatCount(1, autoreverses: true)
withAnimation(animation) {
self.shouldAnimate.toggle()
}
}
}
Obviously this does not quite fulfill my requirements, because let animation = Animation.spring().repeatCount(1, autoreverses: true) only makes sure the animation (to the new state) is being repeated by using a smooth autoreverse = true setting, which still leads to a final state with the view being scaled to scalingFactor.
So neither can I find any property on the animation which lets my reverse my animation back to the original state automatically (without me having to interact with the view after the first animation), nor did I find anything on how to determine when the first animation has actually finished, in order to be able to trigger a new animation.
I find it pretty common practice to animate some View upon its appearance, e.g. just to showcase that this view can be interacted with, but ultimately not alter the state of the view. For example animate a bounce effect on a button, which in the end sets the button back to its original state. Of course I found several solutions suggesting to interact with the button to trigger a reverse animation back to its original state, but that's not what I'm looking for.
Here is a solution based on ReversingScale animatable modifier, from this my answer
Update: Xcode 13.4 / iOS 15.5
Complete test module is here
Tested with Xcode 11.4 / iOS 13.4
struct DemoReverseAnimation: View {
#State var scalingFactor: CGFloat = 1
var body: some View {
Text("hello world")
.modifier(ReversingScale(to: scalingFactor, onEnded: {
DispatchQueue.main.async {
self.scalingFactor = 1
}
}))
.animation(.default)
.onAppear {
self.scalingFactor = 2
}
}
}
Another approach which works if you define how long the animation should take:
struct ContentView: View {
#State private var shouldAnimate = false
private var scalingFactor: CGFloat = 2
var body: some View {
Text("hello world")
.scaleEffect(self.shouldAnimate ? self.scalingFactor : 1)
.onAppear {
let animation = Animation.easeInOut(duration: 2).repeatCount(1, autoreverses: true)
withAnimation(animation) {
self.shouldAnimate.toggle()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(animation) {
self.shouldAnimate.toggle()
}
}
}
}
}

SwiftUI - List editing mode - how to change delete button title?

Is there a way to change the delete button title when editing a List?
Example -
struct ContentView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
Text(user)
}.onDelete(perform: delete)
}.navigationBarItems(trailing: EditButton())
}
}
func delete(source: IndexSet) { }
}
As of Xcode 11.3.1, SwiftUI doesn't support custom swipe actions for List items. Based on the history of Apple’s SDK evolution, we’re not likely to see support until the next major SDK version (at WWDC 2020) or later.
You would probably be better off implementing a different user interface, like adding a toggle button as a subview of your list item, or adding a context menu to your list item.
Note that you must be on beta 4 or later to use the contextMenu modifier on iOS.
See this - SwiftUI - Custom Swipe Actions In List
If you someone who want to adopt this below 15.0, try this.
For this, you need Introspect
List {
ContentsView
.introspectTableView { tv in
tv.delegate = viewModel
}
}
and ViewModel should be...
final class MyCustomViewModel: NSObject, ObservableObject, UITableViewDelegate {
func tableView(_ tableView: UITableView, titleForDeleteConfirmationButtonForRowAt indexPath: IndexPath) -> String? {
return "Kick Out!"
}
}

Move keyboard above TabViewController TabBar

Is it possible to move the keyboard up so it doesn't cover the UITabViewController's TabBar?
Update after being given more context in comments
If your main concern is letting the user dismiss the keyboard, there are some well known patterns that are commonly applied on the platform:
Assumption regarding UI (derived from your comment):
- UITableView as main content
To make the keyboard dismissible, you can utilise a property on UIScrollView called .keyboardDismissMode. (UITableView is derived from UIScrollView, so it inherits the property.)
The default value for this property is .none. Change that to either .onDrag or .interactive. Consult the documentation for differences between the latter two options.
Behind the scenes, UIKit sets up a connection between the UIScrollView instance and any incoming keyboard. This allows the user to "swipe away" the keyboard by interacting with the scroll view.
Note that in order for this feature to work, your UIScrollView needs to be scrollable. To understand what 'scrollable' means in this context, please see this gist.
If your tableView has very few or no rows, it is likely not natively scrollable. To account for that, set tableView.alwaysBounceVertical = true. This will make sure your users can dismiss the keyboard regardless of the number of rows in the table.
Most of the popular apps handling keyboard dismissal also make it possible to dismiss the keyboard simply by tapping the content partially overlapped by it (in your case, the tableView). To enable this, you would simply have to install a UITapGestureRecognizer on your view and dismiss the keyboard in its action method:
class MyViewController: UIViewController {
func viewDidLoad() {
super.viewDidLoad()
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
view.addGestureRecognizer(tapRecognizer)
}
}
//MARK: - Tap handling
fileprivate extension MyViewController {
#objc func handleTap() {
if searchBar.isFirstResponder {
searchBar.resignFirstResponder()
}
// Alternative
// view.endEditing(true)
}
}
// -
Old answer
Yes, you can actually do this without using private API.
Disclaimer
You should really think about whether you actually want to do this. Opening the keyboard in virtually every use case should create a new "context" of editing which modally "blocks" other contexts (such as the navigation context provided by UITabBarController and its UITabBar). I guess one could make the point that users are able to leave an editing context by interacting with a potentially present UINavigationBar which is usually not blocked by keyboards. However, this is a known interaction throughout the system. Not blocking a UITabBar or UIToolbar while showing the keyboard on the other hand, is not. That being said, use the code below to move the keyboard up, but critically review the UX you are creating. I'm not to say it does never make sense to move the keyboard up, but you should really know what you're doing here. To be honest, it also looks kind of iffy, having the keyboard float above the tab bar.
Code
extension Sequence {
func last(where predicate: (Element) throws -> Bool) rethrows -> Element? {
return try reversed().first(where: predicate)
}
}
// Using `UIViewController` as an example. You could and actually should factor this logic out.
class MyViewController: UIViewController {
deinit {
NotificationCenter.default.removeObserver(self)
}
func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: .UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: .UIKeyboardWillHide, object: nil)
}
}
//MARK: - Keyboard handling
extension MyViewController {
private var keyboardOffset: CGFloat {
// Using a fixed value of `49` here, since that's what `UITabBar`s height usually is.
// You should probably use something like `-tabBarController?.tabBar.frame.height`.
return -49
}
private var keyboardWindowPredicate: (UIWindow) -> Bool {
return { $0.windowLevel > UIWindowLevelNormal }
}
private var keyboardWindow: UIWindow? {
return UIApplication.shared.windows.last(where: keyboardWindowPredicate)
}
#objc fileprivate func keyboardWillShow(notification: Notification) {
if let keyboardWindow = keyboardWindow {
keyboardWindow.frame.origin.y = keyboardOffset
}
}
#objc fileprivate func keyboardWillHide(notification: Notification) {
if let keyboardWindow = keyboardWindow {
keyboardWindow.frame.origin.y = 0
}
}
}
// -
Caution
Note that if you are using the .UIKeyboardWillShow and .UIKeyboardWillHide notifications to account for the keyboard in your view (setting UIScrollView insets, for example), you would have to also account for any additional offset by which you move keyboard window.
This works and is tested with iOS 11. However, there is no guarantee that the UIKit team won't change the order of windows or something else that breaks this in future releases. Again, you are not using any private API, so AppStore review should not be in danger, but you are doing something that you're not really supposed to do with the framework, and that can always come around and bite you later on.

Resources