popToRootViewController not returning popped UIViewControllers - ios

I found some strange behaviour with popToRootViewController method.
According to it's declaration it should return popped viewControllers, but it's not if you call it during the pushViewController
class HomeTabNavigationController: UINavigationController {
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
let viewControllers = super.popToRootViewController(animated: animated)
if let vcs = viewControllers, vcs.count > 0 {
print("POPPED SOMETHING")
}
return viewControllers
}
}
This is working as expected, if animation did finish before I call this.
But if I call this method during the animation still happening, it will return nil but it still pops to root viewcontroller after a slight delay.
I prepared a quick demo of that problem:
https://github.com/grzegorzkrukowski/UINavigationControllerBug
Of course I have different use case in my real app, but problem is the same.
Am I missing something or it's just a bug ?

Related

UINavigationController view controller stack order is incorrect when using the "long press back button" feature in iOS 14

I've noticed that, when using the long-press back button feature in iOS 14, any properties relating to UINavigationController's view controller stack (.viewControllers, .topViewController, etc.) seem incorrect. Specifically, the order is reversed.
Regarding the .viewControllers property, Apple's docs state:
The root view controller is at index 0 in the array, the back view controller is at index n-2, and the top controller is at index n-1, where n is the number of items in the array.
If I've got three view controllers in a nav stack like as follows
[ViewController01, ViewController02, ViewController03] and print out the .viewControllers property in viewWillAppear, I get the expected output of:
[ViewController01]
[ViewController01, ViewController02]
[ViewController01, ViewController02, ViewController03]
If I tap the back button from ViewController03, I get the expected output from viewWillAppear in ViewController02:
[ViewController01, ViewController02]
However, if I set everything up again so I've got [ViewController01, ViewController02, ViewController03] and then use the long-press back button feature to jump back to ViewController01, I get the unexpected output of:
[ViewController03, ViewController01]
From viewWillAppear in ViewController01.
I'm not expecting this because ViewController03 isn't, and never was, the root view controller of the navigation stack. As per the docs, I'm expecting:
[ViewController01, ViewController03]
Could someone please let me know if this is expected behaviour or if I've overlooked something super-obvious?
Thank you!
I've reproduced this in a small sample app based on a "single view controller" project. Just embed the initial view controller in a nav stack and include the following:
class StubViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
print("\(self) will appear. Current nav stack follows:")
print("\(self.navigationController?.viewControllers ?? [])")
}
}
class ViewController: StubViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.navigationController?.pushViewController(TestViewController01(), animated: true)
}
}
class TestViewController01: StubViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.navigationController?.pushViewController(TestViewController02(), animated: true)
}
}
class TestViewController02: StubViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.navigationController?.pushViewController(TestViewController03(), animated: true)
}
}
class TestViewController03: StubViewController {
}
(I'm aware the above is very horrible)
Basically, what's happening here is that you have accidentally looked inside the sausage factory and you've seen how the sausage is made. And it isn't pretty...!
The workaround is: don't do that. Give the view controllers stack a chance to settle down before you look at it:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
DispatchQueue.main.async {
print("\(self) will appear. Current nav stack follows:")
print("\(self.navigationController?.viewControllers ?? [])")
}
}
Even more simple: just move the code to viewDidAppear.
i'm stuck with this too, but what i've found is navigation controller changed the viewControllers stack because of animation to handle what controller will goes to another in back operation
for example if stack is [vc1, vc2, vc3, vc4] and you are in vc4 and if you call
navigationController.popToRootViewController(animated: true)
stack will be [vc4, vc1] in willShow delegate method.
the only way i found is to set animation to false
navigationController.popToRootViewController(animated: false)
to keep the stack as what it really is

inputAccessoryView disappears after presenting UIViewController

I have a UICollectionViewController with an inputAccessoryView. Everything works great until I present a UIViewController, and then the accessory view disappears. Trying to get basic Chat application features.
I have implemented in the collection view:
override var inputAccessoryView: UIView? {
get {
return inputContainerView
}
}
override var canBecomeFirstResponder: Bool {
return true
}
override func becomeFirstResponder() -> Bool {
return true
}
As suggested in multiple other threads, I also call (in the collection view),
view.resignFirstResponder()
view.inputAccessoryView?.reloadInputViews()
view.becomeFirstResponder()
after dismissing the UIViewController but to no avail. print(view.isFirstResponder) still prints false. I have tried almost every combination of the above three lines in numerous different places in my code. I think I'm missing something simple.
The animation to present and dismiss view controller might be causing issue or You haven't maintained a global ivar for the view you have setted as input accessory view. Try creating a readonly ivar for the accessoryview so only one instance is allocated and maintained through out VC life cycle. Then ensure to set it back to the textfields before calling reloadInputViews.
After a few days I finally figured out something that works... I think I was trying to present the loginController before the collectionView was set as the first responder. Instead of just calling present I called this function:
func presentLoginControllerAfterImFirstResponder(fromUser: Bool) {
// Starts a timer
Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { (timer) in
// Is this view the first responder?
if self.isFirstResponder {
// Creates the loginController
let loginController = LoginController()
// Presents it
self.present(loginController, animated: fromUser, completion: {
// Once presented, sets rootViewController = self
loginController.rootViewController = self
})
// Stop the timer
timer.invalidate()
}
}
}
This works now. I guess the collectionView needed some time to set itself as the first responder? I can present and dismiss the loginController no problem and the inputAccessoryView remains.

Pushing View Controller Twice

Within my app I'm having an issue with the following error:
Pushing the same view controller instance more than once is not supported
It's a bug report that's comeback from a few users. We've tried to replicate it but can't (double tapping buttons etc). This is the line we use to open the view controller:
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let editView = storyboard.instantiateViewControllerWithIdentifier("EditViewController") as! EditViewController
editView.passedImage = image
editView.navigationController?.setNavigationBarHidden(false, animated: false)
if !(self.navigationController!.topViewController! is EditViewController) {
self.navigationController?.pushViewController(editView, animated: true)
}
Anybody have any ideas? I've done a bit of research and most answers on Stack we've covered so are at a bit of a loss for how to investigate.
Try this to avoid pushing the same VC twice:
if !(self.navigationController!.viewControllers.contains(editView)){
self.navigationController?.pushViewController(editView, animated:true)
}
As the pushViewController is asynchronous since iOS7, if you tap the button that push view controller too fast, it will be pushed twice.
I have met such issue, the only way I tried is to set a flag when push is invoked (i.e - navigationController:willShowViewController:animated:) and unset the flag when the delegate of UINavigationController is called - navigationController:didShowViewController:animated:
It's ugly, but it can avoid the twice-push issue.
In the function that does the push:
guard navigationController?.topViewController == self else { return }
The completion block of CATransaction to the rescue :)
The animation of pushViewController(:animated:) is actually pushed onto the CATransaction stack, which is created by each iteration of the run loop. So the completion block of the CATransaction will be called once the push animation is finished.
We use a boolean variable isPushing to make sure new view controller can't be pushed while already pushing one.
class MyNavigationController: UINavigationController {
var isPushing = false
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
if !isPushing {
isPushing = true
CATransaction.begin()
CATransaction.setCompletionBlock {
self.isPushing = false
}
super.pushViewController(viewController, animated: animated)
CATransaction.commit()
}
}
}

iOS App Freezes on PushViewController

My navigation controller intermittently will freeze on push. It seems to add the new view controller onto the stack, but the animation never takes place. I also have two other containers that hold view controllers on the screen, and I can interact with both of them just fine after the navigation controller freezes. The really interesting thing is if I try to push another view controller onto the navigation controller's stack, I noticed that there is an extra view controller on top of the stack (the view controller that I pushed initially that froze the navigation controller). So if I'm on the home screen (we'll call it VC-Home) and I try to push a new view (VC-1) and it freezes, then I try to push a new view (VC-2), this is what I see in the current stack before the push:
{ [VC-Home, VC-1] }
and after pushViewController is called, it remains the same; VC-2 is not added to the stack.
From what I can tell, the navigation controller starts the animation by making the previous view controller inactive before the animation begins, but then the animation never takes place, leaving the navigation controller in a frozen state.
I'm creating the new view controller from a storyboard by calling
UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("ViewController") so I don't think there's any issues there. I'm also not overriding pushViewController on the navigation bar. Some unique things about my app is that it is very high-res image heavy (using SDWebImage to manage that) and I always have three containers on the screen at once (one navigation controller, one view controller for search, and one interactive gutter/side/slideout menu).
CPU usage is low and memory usage is normal (steadily around 60-70MB on device when freezes occur).
Are there any ideas with what might be causing this or any debugging tips that could help me discover the real problem?
Update
There's no unique code for the UINavigationController since I'm just pushing using pushViewController(). Here's the code that calls it:
func didSelectItem(profile: SimpleProfile) {
let vc = UIStoryboard.profileViewController()
vc.profile = profile
navigationController?.pushViewController(vc, animated: true)
}
The ViewController that I pushed has the following code in viewDidLoad:
override func viewDidLoad() {
super.viewDidLoad()
button.roundView()
if let type = profile?.profileType {
//load multiple view controllers into a view pager based on type
let viewControllers = ProfileTypeTabAdapter.produceViewControllersBasedOnType(type)
loadViewPagerViews(viewControllers)
let topInset = headerView.bounds.height + tabScrollView.contentSize.height
if let viewPager = viewPager {
for view in viewPager.views {
if let tempView = view as? PagingChildViewController {
tempView.profile = fullProfile
tempView.parentVCDelegate = self
tempView.topInset = topInset
}
}
}
}
}
func loadViewPagerViews(viewControllers: [UIViewController]) {
viewPager?.views = viewControllers
viewPager?.delegate = self
//loading views into paging scroll view (using PureLayout to create constraints)
let _ = subviews.map { $0.removeFromSuperview() }
var i = 0
for item in views {
addSubview(item.view)
item.view.autoSetDimensionsToSize(CGSize(width: tabWidth, height: tabHeight))
if i == 0 {
item.view.autoPinEdgeToSuperviewEdge(.Leading)
} else if let previousView = views[i-1].view {
item.view.autoPinEdge(.Leading, toEdge: .Trailing, ofView: previousView)
}
if i == views.count {
item.view.autoPinEdgeToSuperviewEdge(.Trailing)
}
i += 1
}
contentSize = CGSize(width: Double(i)*Double(tabWidth), height: Double(tabHeight))
}
Update 2
I finally got it to freeze again. The app was in the background and I brought it back and tried pushing a view controller on the stack when it froze. I noticed an animation was taking place. I have a scrollview at the top of the page that pages through its content every 10 seconds (think of the app stores top banner). On this freeze, I noticed that the banner was mid-animation.
Here's the scrolling function from the my UIScrollView that gets called every 10 seconds:
func moveToNextItem() {
let pageWidth: CGFloat = CGRectGetWidth(frame)
let maxWidth: CGFloat = pageWidth * CGFloat(max(images.count, profileImages.count))
let contentOffset: CGFloat = self.contentOffset.x
let slideToX = contentOffset + pageWidth
//if this is the end of the line, stop the timer
if contentOffset + pageWidth == maxWidth {
timer?.invalidate()
timer = nil
return
}
scrollRectToVisible(CGRectMake(slideToX, 0, pageWidth, CGRectGetHeight(frame)), animated: true)
}
I don't recall ever having a push stop because of an animation/scroll taking place, but I could be wrong.
I've also rechecked the stack and the same situation as described above is still the case where [VC-Home, VC-1] is the stack and VC-2 is not pushed on. I've also gone through VC-1's variables and everything has loaded (data calls and image loads).
Update 3
This is getting stranger by the second. I've overriden pushViewController so I can put a breakpoint in there and do some debugging based on Alessandro Ornano's response. If I push a view controller unsuccessfully, then send my app to the background, put a breakpoint into the pushViewController call, and bring the app back, the breakpoint is immediately hit a number of times. If I then continue past all the hits, the next view controller suddenly becomes visible and the last view controller I tried to push is now on the stack as the last view controller. This means that the one that I see is still disabled, which essentially puts me in the same position as before.
We have faced the same problem couple of weeks back. And for our problem we narrowed it down to left-edge pop gesture recogniser. You can try and check if you can reproduce this problem using below steps
Try using the left edge pop gesture when there are no view controllers below it (i.e on root view controllers, your VC-Home controller)
Try clicking on any UI elements after this.
If you are able to reproduce the freeze, try disabling the interactivePopGestureRecognizer when the view controller stack have only one view controller.
Refer to this question for more details. Below is the code from the link for ease of reference.
- (void)navigationController:(UINavigationController *)navigationController
didShowViewController:(UIViewController *)viewController
animated:(BOOL)animate
{
if ([self respondsToSelector:#selector(interactivePopGestureRecognizer)])
{
if (self.viewControllers.count > 1)
{
self.interactivePopGestureRecognizer.enabled = YES;
}
else
{
self.interactivePopGestureRecognizer.enabled = NO;
}
}
}
Great answer by #Penkey Suresh! Saved my day! Here's a SWIFT 3 version with a small addition that made the difference for me:
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
if (navigationController.viewControllers.count > 1)
{
self.navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController.interactivePopGestureRecognizer?.isEnabled = true;
}
else
{
self.navigationController?.interactivePopGestureRecognizer?.delegate = nil
navigationController.interactivePopGestureRecognizer?.isEnabled = false;
}
}
Just don't forget to add UINavigationControllerDelegate and set the navigationController?.delegate = self
Another important part is to assign the interactivePopGestureRecognizer to self or to nil accordingly.
I was thinking about intermittently freezing, main thread and SDWebImage.
Assuming that you using the image you downloaded from downloadImageWithURL:options:progress:completed:'s completed block .. if so, make sure you dispatch to the main queue before using using the image.
If you use the SDWebImageDownloader directly, the completion block (as you noted) will be invoked on a background queue, you can fix it using dispatch_async on the main queue from the completion.
Otherwise you can use:
SDWebImageManager downloadImageWithURL:options:progress:completed: (method that invokes the completion blocks on the main queue).
if the problem persist (just because you speaking about "..some unique things about my app is that it is very image heavy..") look also Common problems expecially the Handle image refresh know problems.
Add to your check also this nice snippet code:
import UIKit.UINavigationController
public typealias VoidBlock = (Void -> Void)
public extension UINavigationController
{
public func pushViewController(viewController: UIViewController, animated: Bool, completion: VoidBlock) {
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
self.pushViewController(viewController, animated: animated)
CATransaction.commit()
}
}
maybe can help to understand if pushViewController finish, if finish with all viewControllers expected ..
Another test I try to make is to launch the app with iOS 8.x and iPhone 6+, because there are some issues in the pureLayout project around iOS 9. Can you send feedbacks around this test?
I've some suspicious also on the real scrollview dimension before the pushview action, can you analyze the current view by examing the view hierarchy?
Please check if you have any unnecessary codes like below in your BaseNavigationViewController :
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
Here I have a sample code which reproduces this issue (freezing app in pushing VC) that you have to reproduce it by these steps:
Try using the (left | right) edge pop gesture when there are no view controllers below it (i.e on root view controllers, your VC-Home controller)
Try clicking on any UI elements after this(which pushes you to next ViewController).
Sample Code: https://github.com/aliuncoBamilo/TestNavigationPushBug
My case solved by restarting Xcode and simulator, then choosing a different device from simulators list.
Answer by #Penkey Suresh! and #Tim Friedland helped me alot, one thing that i had to do extra for my usecase, might help someone else too.
USE CASE:
I had tab bar controller and i wanted swipe-back-gesture on screens let's say i'm on tabA and opened VC1, VC2 from there. My swipe gesture was working correctly on VC1 and VC2 but for some reason it was not disabling when coming back to tabA which is why it was freezing when trying to swipe left from there and trying to click somewhere (as mentioned by #Pankey Suresh)
SOLUTION IN SWIFT 5
I have this custom class implemented:
class BaseSwipeBack: UIViewController, UIGestureRecognizerDelegate {
func swipeToPop(enable: Bool) {
if enable && (navigationController?.viewControllers.count ?? 0 > 1){
self.navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true;
}
else {
self.navigationController?.interactivePopGestureRecognizer?.delegate = nil
navigationController?.interactivePopGestureRecognizer?.isEnabled = false;
}
}
}
USAGE
VC1 class - make it sub class of BaseSwipeBack that we just created so that you can access the functions
class VC1: BaseSwipeBack {
override func viewDidLoad() {
}
override func viewDidAppear(_ animated: Bool) {
self.swipeToPop(enable: true)
}
deinit {
self.swipeToPop(enable: false)
}
}
tabAController class - although i have disabled gesture in deiniting VC1 but it was not working fine so i had to disable it again tabAController
class tabAController: BaseSwipeBack {
override func viewDidLoad() {
}
override func viewDidAppear(_ animated: Bool) {
self.swipeToPop(enable: false)
}
}

An alternative to "Storyboard Reference" for iOS 8 when handling Relationship Segue?

My App has a TabBarViewController containing 4 tabs. One of the tabs is Settings which I want to move to a separate storyboard. If I am only consider iOS 9 and above as my deployment target, then I can just refactor the SettingsTab using Storyboard Reference. However I want to target iOS 8 as well. Since Storyboard Reference doesn't support Relationship Segue, I can't rely on it in this case.
So in the main storyboard which contains the TabBarViewController, I keep a dummy SettingsTabViewController as an empty placeholder. And in the function "viewWillAppear" in its class file, I push the view to the real SettingsTabViewController in the Settings.storyboard. This works fine. But the problem is if I keep tabbing the Settings tab, the empty placeholder view controller will show up for a short time and then goes back to the real Settings view.
I tried to implement this delegate to lock the Settings tab:
func tabBarController(tabBarController: UITabBarController, shouldSelectViewController viewController: UIViewController) -> Bool {
return viewController != tabBarController.selectedViewController
}
However, the other three tabs were locked too after I implemented this delegate.
Is it possible to just lock the Settings tab without locking other three tabs? And in which view controller exactly should I implement this delegate?
Yes, it's possible. You need to check the index;
with the following code not only you can prevent locking other tabs, but also you still have tap on tab goto root view controller feature.
func tabBarController(tabBarController: UITabBarController, shouldSelectViewController viewController: UIViewController) -> Bool {
let tappedTabIndex = viewControllers?.indexOf(viewController)
let settingsTabIndex = 3 //change the index
if tappedTabIndex == settingsTabIndex && selectedIndex == settingsTabIndex {
guard let navVC = viewController as? UINavigationController else { return true }
guard navVC.viewControllers.count > 1 else { return true }
let firstRealVC = navVC.viewControllers[1]
navVC.popToViewController(firstRealVC, animated: true)
return false
}
return true
}
.
This answers your question, but still you would have the settingsVC showing up for a moment. To avoid this you simply need to turn off the animation while you're pushing it. so you need to override viewWillAppear in the following way.
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if let theVC = storyboard?.instantiateViewControllerWithIdentifier("theVC") {
navigationController?.pushViewController(theVC, animated: false)
}
}
after adding above code you still would see a back button in your real first viewController. You can hide it:
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.hidesBackButton = true
}

Resources