📜 ⬆️ ⬇️

We sell our dropDown ViewController (aka iOS 8 Mail app) in 200 lines

Even with the beta version of iOS 8, I really liked this new feature of the mail application: when creating a new letter, you can simply swipe this window down and continue working on the previous screen. I'm not sure how this feature turned out to be useful specifically in this application, but the idea is great! That evening I sat down to do the same thing, and did make my bike, and for a while I forgot about it.

Recently, I needed a similar functionality. Not wanting to take my old decision, and not finding a ready implementation that I would have liked, it was decided to write my own. What came out of it, what difficulties had to be faced, and what was new was brought out - under the cut.

image

What solution did I want? This, because of which you do not have to rebuild something in the existing structure of the project, which was as small and simple as possible (and who doesn’t want?) - just a working black box. For this reason, for example, I did not like this solution, here a friend suggests using his viewController as root, and installing navigation in this style:
')
self.viewController = [[ARTEmailSwipe alloc] init]; // you will want to use your own custom classes here, but for the example I have just instantiated it with the UIViewController class. self.viewController.centerViewController = [[UIViewController alloc] init]; self.viewController.bottomViewController = [[UIViewController alloc] init]; 

Yes, and the implementation of it takes ~ 400 lines, all this can not upset.

First, about how I implemented it myself before:
Code
  vcModal = [storyboard instantiateViewControllerWithIdentifier:@"vcModal"]; vcModal.modalPresentationStyle = UIModalPresentationCustom; vcModal.delegate = self; [self addChildViewController: vcModal]; vcModal.view.frame = self.view.bounds; [self.view addSubview: vcModal.view]; [self.view bringSubviewToFront:vcModal.view]; [vcModal didMoveToParentViewController: self]; CGRect bound = [[UIScreen mainScreen] bounds]; CGRect finalFrameVC = vcAddNewGoal.view.frame; vcAddNewGoal.view.frame = CGRectOffset(finalFrameVC, 0, CGRectGetHeight(bound)); //       // … 


To put it mildly, this is not the most elegant solution; it imposes its own limitations, plus another fuss with the new controller then removed. Why did I not immediately use UIViewControllerAnimatedTransitioning? Honestly, I don’t remember, maybe at the beginning and started to do with it, but faced with the difficulty, which I’ve mentioned below, I threw it and decided to make such a crutch.

UIViewControllerAnimatedTransitioning

About the use of this protocol, which exists since the days of iOS 7, did not write except that lazy. There are hundreds of tutorials and articles. The beauty is that the protocol itself is very simple. You need to implement only 2 mandatory methods: transitionDuration: - in which the animation time is returned, and animateTransition: in which the animation itself in the View of Controllers happens. Nothing easier, right? I thought. And here the animation method is joyfully written:

animateTransition:
 - (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext{ self.transitionContext = transitionContext; UIViewController *fromtVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; UIView *containerView = [transitionContext containerView]; CGRect finalFrameVC = [transitionContext finalFrameForViewController:toVC]; NSTimeInterval duration = [self transitionDuration:transitionContext]; viewH = CGRectGetHeight(fromtVC.view.frame); //   vc  ,      UIViewController *modalVC = reversed ? fromtVC : toVC; UIViewController *nonModalVC = reversed ? toVC : fromtVC; //       ,     CGRect modalFinalFrame = reversed ? CGRectOffset(finalFrameVC, 0, viewH) : finalFrameVC; float scaleFactor = 0.0; float alphaVal = 0.0; if (reversed) { scaleFactor = 1.0; alphaVal = 1.0; } else { //         modalFinalFrame.origin.y += kModalViewYOffset; //      modalVC.view.frame = CGRectOffset(finalFrameVC, 0, viewH); scaleFactor = kNonModalViewMinScale; alphaVal = kNonModalViewMinAlpha; [containerView addSubview:toVC.view]; } [UIView animateWithDuration:duration delay:0.0 usingSpringWithDamping:100 initialSpringVelocity:10 options:UIViewAnimationOptionAllowUserInteraction animations:^{ nonModalVC.view.transform = CGAffineTransformScale(CGAffineTransformIdentity, scaleFactor, scaleFactor); nonModalVC.view.alpha = alphaVal; modalVC.view.frame = modalFinalFrame; } completion:^(BOOL finished) { [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; reversed = !reversed; }]; 



The modal window itself was moved using UIPercentDrivenInteractiveTransition. It seems that everything works, the window appears, moves, closes. But, all this was conceived, so that when the modal window is at the bottom, you could work with the previous screen, and the previous screen does not respond to pressing! This was the second of the recent disappointments, after the news of the closure of Parse. It seemed to me the most logical to add the fromtVC screen to the containerView when opening it. And it worked - the previous screen was active, although now only the black screen remained at the close.



After reading the documentation and stackOverflow, it became clear that it was impossible to add to the fromVC container in any way, but what was to be done was also not clear. Having described my problem, I asked a question for SO, I even asked a question for toster, but all was not answered.
I suddenly realized that I was not fully aware of the whole mechanism of the animateTransition: method. That is, there is some object
containerView, an opening controller is added to it, but what does it represent, what is the place in the view hierarchy, what happens to the previous controller? I was sure that by answering these questions I would find a solution (spoiler - and I was not mistaken). I did just:

 containerView.backgroundColor = [UIColor yellowColor]; 


Before



After



It became clear that containerView is the usual transparent UIView added above the previous view, and beneath it lies peacefully fromVC. So, this container interferes with interacting with it, it’s not an option to move it, it means you need to “push” through it. The easiest way to get a UIView to transmit clicks "through" itself is to set it
 userInteractionEnabled = NO; 
, but then it will spread to all its subview, which is also not an option.

Responder Chain

If you have not come across this before, then let me introduce you to the Responder Chain . In short, the Responder Chain is an iOS mechanism that is responsible for sending an event, such as a click, to the corresponding object. The event “travels” along this chain until it reaches an object that can receive and process it. In the case of a click, the UIWindow object first tries to deliver the event to the view where the click occurred. This view is known as the “hit-test view”, and the search process for this hit-test view is called hit-testing. Hit-testing involves checking that a click has occurred within a suitable view, and then recursively checks all of its subviews. The lowest level view in this hierarchy is within hit range, and becomes a hit-test view, after which iOS sends an event to this view for processing

An excellent illustration of this process from the documentation:



Suppose the user clicked on view E. iOS finds the hit-test view by checking the subview in this order:
1. Pressing within view A, check B and C.
2. Pressing not within B, but within C, we check D and E.
3. Pressing is not within D, but within E. E is the lowest-level view in the hierarchy, containing click coordinates, so that it becomes a hit-test view

Why was all this story? And then, that the UIView method - hitTest: withEvent: can be rewritten!

The task was the following: to make it possible to click through the containerView, and at the same time to click on its subviews as usual. You cannot write a subclass and force containerView from it. Sort of:

 MyUIViewSubclass *containerView = (MyUIViewSubclass *)[transitionContext containerView]; 
- will not work. So you need to create a category (or, as in Russian literature, the “category of the continuation of a class”). The “standard” method hitTest: withEvent: looks like this:

 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) { return nil; } if ([self pointInside:point withEvent:event]) { for (UIView *subview in [self.subviews reverseObjectEnumerator]) { CGPoint convertedPoint = [subview convertPoint:point fromView:self]; UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event]; if (hitTestView) { return hitTestView; } } return self; } return nil; } 

That is, if we clicked somewhere, and in our chain (responder chain) we see a view with isUserInteractionEnabled disabled or hidden, or transparency> 99% - return nil, by which we say to continue the test, “skip through” this click. If otherwise, we try to find a hitTest view and if it is found — return it, that will send a click event to this view, or return nil — and nothing will happen.

Now how to make sure that the click on the container is not transmitted? It is necessary to somehow distinguish between containerView, the simplest thing is to simply set the tag

 UIView *containerView = [transitionContext containerView]; containerView.tag = GITransitionContainerViewTag; 

Tag'om I chose the best number 73 :).
And in the hitTest method: withEvent: an additional condition is added:

  if (hitTestView && hitTestView.tag != GITransitionContainerViewTag) { return hitTestView; } 

Thus, pressing will never “settle” in containerView, but will go deeper in the hierarchy.
But there is a big BUT . By doing so, by redefining the standard behavior of UIView, we change this behavior absolutely for all UIViews in the program, and not just for the containerView — which is undesirable (thanks to habrayumer for pointing this out in the comments). To fix this, you can use the objective-C runtime, namely, the mechanism for switching method implementations (method swizzling).
This will require a minimum of code changes:
1) in the same UIView category, add a prefix to the hitTest: WithEvent method, for example:
  - (UIView *)GI_hitTest:(CGPoint)point withEvent:(UIEvent *)event; 

2) After receiving the reference to the containerView, we switch the method:
  static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // Swizzling Method originalMethod = class_getInstanceMethod([containerView class], @selector(hitTest:withEvent:)); Method swappedMethod = class_getInstanceMethod([containerView class], @selector(GI_hitTest:withEvent:)); method_exchangeImplementations(originalMethod, swappedMethod); }); 

Thus, the overridden hitTest: WithEvent method is called only for containerView, and does not touch other UIViews in the system.
Now everything works as intended. Thank you for reading, I hope you learned something new and interesting for yourself.

If you are interested, the project is on GitHub

Source: https://habr.com/ru/post/276799/


All Articles