2

I have put a UINavigationController into an expandable "Drawer". My goal is to let each viewController in the navigation stack to have its own "preferred" height.

Let's say VC1 needs to be tall. When navigating back to VC1 from VC2 I want it to animate its height to be tall. The animation logic seems to be working, even with the interaction of swipe.

But for some reason, the viewControllers in the navigationController are "cut off". Their constraints are correct, but they aren't updated(?). Or a portion of the content simply won't render, until I touch the view again. The invisible area on the bottom will even accept touches.

Take a look:

gif

The expected result is that the contentViewControllers (first and second) always extend to the bottom of the screen. They are constraint to do this, so the issue is that they won't "render"(?) during the transition.

In the UINavigationController's delegates, I do the following:

func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
    transitionCoordinator?.animate(alongsideTransition: { (context) in
            drawer.heightConstraint.constant = targetHeight
            drawer.superview?.layoutIfNeeded()
        }, completion: { (context) in
            print("done")
        })
}

The height change is perfect. But the content in the navigation won't comply. The navigationController is constrained to leading, trailing, bottom, and a stored heightConstraint that changes its constant.

As soon as I touch/drag the navigationController/content it instantly "renders the unrendered", and everything is fine. Why is this happening?

When inspecting the view hierarchy, it looks like this:

enter image description here

The NavigationController is as tall as it needs to be, but the content is the same height as the entire Drawer was when the transition started, and it doesn't update until I touch it. Why?

Edit: I've pushed the code to my GitHub if you want to take a look. Beware though, there are several other issues there as well (animation etc.), don't mind them. I only want to know why the navigation won't render "future" heights.

Sti
  • 8,275
  • 9
  • 62
  • 124
  • 1
    Do you have this sample hosted in github? If so, can you share the link? – Subramanian Mariappan Dec 04 '19 at 09:03
  • If you use a generic containing viewController with child ViewController you will have much more control and more visibility in what is going on. – David H Dec 04 '19 at 14:57
  • @SubramanianMariappan Added link to github. – Sti Dec 04 '19 at 17:42
  • @DavidH It is a `UIView` containing a `UINavigationController` containing a `UIViewController`. The reason for using the navigationController is to get the free navigation-animation and logic. Do you mean I should insert a UIViewController "my stack" somewhere? Wouldn't that just be the same as my base UIView, since I'd just `addSubview(vc.view)`? Or do you mean I should remove the navigationController and make my own navigation? Added link to github if you want to take a look. – Sti Dec 04 '19 at 17:47
  • @Sti my point is that often when you try to bend Apple's "Container" View Controllers to do something they don't do out of the box, you hit walls that you just cannot hurtle (I wish I had the hours back I spent on trying to bend UISplitViewController). Get an older copy of Matt Neuburg's Programming iOS XX books (used really cheap - the new iOS13 won't be out until 2020). Read the section "Container View Controllers". You can use your own Navigation Bar, and make this thing do whatever you want - you will have 100% control. This will appear to take longer, but the work will be linear. – David H Dec 04 '19 at 20:15
  • I've downloaded your project from GitHUB, I've launched it with Xcode 11.2.1 and I don't have your issues, it works very well as you expected.. – Alessandro Ornano Dec 05 '19 at 13:21
  • @AlessandroOrnano Yeah sorry, someone made a pull request to fix it their way, and I merged it into master to see if it works, because I'm dumb and have no idea how github works. So just jump back to the first commit to see the issue. The second commit is "fixed" as the answer posted below, with AnimatedTransitioning (though still with some issues). I'll see if I can revert it and put it in a branch or something instead. – Sti Dec 05 '19 at 13:25
  • 1
    @AlessandroOrnano I quickfixed the branches and "reverted" master now. Subramanians solution *did* fix the requested issue, but at the cost of losing a lot of the native transition in the navigationController. Feel free to try something else, I'm not in a hurry. – Sti Dec 05 '19 at 13:54
  • https://github.com/sfla/Drawer/pull/4 – SPatel Dec 07 '19 at 06:25

4 Answers4

3

You can solve the above problem by having a custom animationController for your navigationController and setting the appropriate frame for the destinationView in animateTransition function of your custom UIViewControllerAnimatedTransitioning implementation. The result would be like the one in the following gif image.

enter image description here

Your custom UIViewControllerAnimatedTransitioning may look like the one below.

final class TransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    let presenting: Bool

    init(presenting: Bool) {
        self.presenting = presenting
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return TimeInterval(UINavigationController.hideShowBarDuration)
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromView = transitionContext.view(forKey: .from) else { return }
        guard let toView = transitionContext.view(forKey: .to) else { return }

        let duration = transitionDuration(using: transitionContext)

        let container = transitionContext.containerView
        if presenting {
            container.addSubview(toView)
        } else {
            container.insertSubview(toView, belowSubview: fromView)
        }

        let toViewFrame = toView.frame
        toView.frame = CGRect(x: presenting ? toView.frame.width : -toView.frame.width, y: toView.frame.origin.y, width: toView.frame.width, height: toView.frame.height)

        UIView.animate(withDuration: duration, animations: {
            toView.frame = toViewFrame
            fromView.frame = CGRect(x: self.presenting ? -fromView.frame.width : fromView.frame.width, y: fromView.frame.origin.y, width: fromView.frame.width, height: fromView.frame.height)
        }) { (finished) in
            container.addSubview(toView)
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }
}

And, in your navigation controller provide custom animationController as follows.

func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
   switch operation {
   case .push:
      return TransitionAnimator(presenting: true)
   case .pop:
      return TransitionAnimator(presenting: false)
   default:
      return nil
   }
}

PS :- I've also given a pull request in your github repo.

Subramanian Mariappan
  • 3,736
  • 1
  • 14
  • 29
  • This is great! and I will probably accept this answer (can't award bounty until tomorrow anyway). But - I noticed that this solution means losing a few nice native transition features. First off, it's harder to start the swipe (on real device). Maybe it's crashing with the native swipe? Also, the swiped amount doesn't follow the finger, it shows some kind of "easeInOut"-behaviour that I can't identify. But most noticeably, the navigationBar doesn't transition anymore. The back-button / back-title / title doesn't follow the swipe. Do I have to manually replicate these, or can we inherit some? – Sti Dec 04 '19 at 20:44
  • Swipe can be made easy by customizing pan gesture recognizer added to the view. Had given a pull request with the change. – Subramanian Mariappan Dec 05 '19 at 01:09
  • With your last PR it's easier to start the swipe, but now impossible to drag to expand the entire Drawer, which is kind of a key feature for this element. There's probably a way to make some "simultaneous"-logic to make it work. I'll play around with it. But it's still not a completely perfect answer, as I still lose the transition of the navigationBar - but I'll play around with that too. Your answer solves the main issue, but creates a few other issues. It's a good answer, and I will accept it if nobody else solves it without side-effects the next 6 days. – Sti Dec 05 '19 at 14:02
  • (and I moved your pull requests to its own branch and reset master so that other people can try if they want) – Sti Dec 05 '19 at 14:03
2

You can update controllers height yourself:

first you need to keep a reference to controllers:

class ContainedNavigationController: UINavigationController, Contained, UINavigationControllerDelegate {

    private var controllers = [UIViewController]()
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { controllers = viewControllers }

/* Rest of the class */

Then you can update their heights accordingly. Don't forget to add new controller.

    private func updateHeights(to height: CGFloat, willShow controller: UIViewController) {
        controller.view.frame.size.height = height
        _ = controllers.map { $0.view.frame.size.height = height }
    }

You can use it in your code like this:

    func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        
        if let superHeight = view.superview?.superview?.bounds.size.height, let drawer = (view.superview as? Drawer), let targetHeight = (viewController as? Contained)?.currentNotch.height(availableHeight: superHeight){
            
            transitionCoordinator?.animate(alongsideTransition: { (context) in
                drawer.heightConstraint.constant = targetHeight
                drawer.superview?.layoutIfNeeded()
                
                self.updateHeights(to: targetHeight, willShow: viewController) // <- Here
            }, completion: { (context) in
                self.updateHeights(to: targetHeight, willShow: viewController) // <- Here
            })
        }
    }

Result:

Demo

Maybe this is not the cleanest code can done, but I just want to solve the issue and giving you the idea


Update

That shadow you have seen so far when you drag from the edge is a view called UIParallaxDimmingView. I have added a fix for that size too. So no more visual issues:

    private func updateHeights(to height: CGFloat, willShow controller: UIViewController) {
        controller.view.frame.size.height = height
        _ = controllers.map { $0.view.frame.size.height = height }

        guard controllers.contains(controller) else { return }
        _ = controller.view.superview?.superview?.subviews.map {
            guard "_UIParallaxDimmingView" == String(describing: type(of: $0)) else { return }
            $0.frame.size.height = height
        }
    }

I have added a pull request from my fork.

Community
  • 1
  • 1
Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
  • Brilliant! I've always just assumed it would fail to explicitly set the frame of a controllers intrinsic `view` because of mysterious rules regarding UIViewController in UIWindow. A few questions though: 1. Why manually keep reference to the controllers? Since this is a `UINavigationController`, can't we just use self.viewControllers to do this? In fact, why apply the height for ALL viewControllers, and not just the originViewController and destinationViewController during the transition (given that it's possible to identify origin, I haven't tried yet). – Sti Dec 05 '19 at 22:36
  • 2. There is some visible shadow-artifact happening in the very bottom left. Visible here: https://imgur.com/eLpaORs . It seems there is a "shadowView" applied by the UINavigationController that doesn't receive the same height change. Any idea how that can be solved without accessing private APIs? – Sti Dec 05 '19 at 22:37
  • 1. As I said, this isn’t *the best code*. The important thing is the idea behind that. You can play with parameters and see what is going to happen if you don’t keep references. As a summary, some times one of the them failed to respond to sizing. Because the function is calling after navigation controller releases the pointer. – Mojtaba Hosseini Dec 05 '19 at 22:56
  • 2. Hmmm. I have to look for a fix for that. I hope I can help you with that to reach the perfection. – Mojtaba Hosseini Dec 05 '19 at 22:57
  • I've been playing a bit with your solution, and I've found a way to only change size of the relevant viewControllers. It was simple enough to override `popViewController` and only store that as a single pointer, and always change its height if it's not nil (and set it to nil in `pushViewController`), because these are called before `willShow`. Everything works as desired, except for that shadow-view.. – Sti Dec 06 '19 at 21:49
  • Nice, better code means better world! I hope that problem be solve soon – Mojtaba Hosseini Dec 06 '19 at 22:31
  • Ok I have added a fix for that shadow issue. Also added a pull request. You can check it out. – Mojtaba Hosseini Dec 09 '19 at 20:41
  • Good job finding it! It looks perfect. I am hesitant to use the shadow-logic as it is modifying a private property. But as far as I understand, the real danger about private properties is that they may not be there in a future iOS-version, without migration/deprecation, which could make everything crash if used incorrectly. But in the case of your code, if the private property were to be removed, the code would just do *nothing* instead of crashing, which is good. – Sti Dec 10 '19 at 09:00
  • This is the only answer that got me exactly what I wanted, so it's accepted. Though, it feels like we're fighting the native behavior of UINavigationController/ViewController, so I'll still consider actually using it in production. As David H mentioned in the comments of the question, there is a point where it's just better to create the entire thing yourself, instead of fighting the system. I'll do some testing across devices etc, and I'll figure out what I go for. Thanks for the answer! – Sti Dec 10 '19 at 09:03
  • Don't worry about the crashing, since it's just a check and will not crash if apple changes it in the future. And if you want, you can use `hasSuffix("UIParallaxDimmingView")` instead or change all subviews to get rid of that `_` in the code – Mojtaba Hosseini Dec 10 '19 at 09:05
0

You can to set size like bellow: refrence -> https://github.com/satishVekariya/Drawer/tree/boom-diggy-boom

class FirstViewController: UIViewController, Contained {
   // your code 

   override func updateViewConstraints() {
       super.updateViewConstraints()
       if let parentView = parent?.view {
          view.frame.size.height = parentView.frame.height
       }
   }   
}

Your navigation vc:

class ContainedNavigationController:UINavigationController, Contained, UINavigationControllerDelegate{
   ///Your code

   func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
       viewController.updateViewConstraints()
           // Your code start
           // end
   }

   func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
       viewController.updateViewConstraints()
   }

}
SPatel
  • 4,768
  • 4
  • 32
  • 51
0

You can set height constraint and call layoutIfNeeded method without transition block.

Below is the code snippet for the same:

func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {

    if let superHeight = view.superview?.superview?.bounds.size.height, let drawer = (view.superview as? Drawer), let targetHeight = (viewController as? Contained)?.currentNotch.height(availableHeight: superHeight){
        drawer.heightConstraint.constant = targetHeight
        drawer.layoutIfNeeded()
    }
}

@sti Please let me know if it helps, please do up vote for the same

  • That's just my bad during creating this post/example. I am aware that I should change the constraint outside. It just ended up inside because I testet a lot of different stuff. But - in reality, it doesn't change anything at all, since it's not an animatable property. And calling layoutIfNeeded on drawer rather than drawer.superview is just wrong. Feel free to try your own solution on the sample code I provided. It makes it worse. – Sti Dec 10 '19 at 08:31