Реализуем свой dropDown ViewController (aka iOS 8 Mail app) в 200 строк

от автора

Еще с beta версии iOS 8 мне очень понравилась эта новая фича приложения почты: при создании нового письма можно просто смахнуть это окно вниз и продолжить работу на предыдущем экране. Не уверен насколько эта фича оказалась полезной конкретно в этом приложении, но идея то отличная! В тот же вечер я сел делать подобную штуку, и таки сделал свой велосипед, и на время забыл об этом.
Недавно мне понадобился похожий функционал. Не захотев брать свое старое решение, и не найдя готовой реализации, которая бы мне понравилось, было решено написать свое. Что из этого получилось, с какими трудностями пришлось столкнуться, и что нового было вынесено — под катом.

image

Какого решения мне хотелось? Такого, из за которого не придется что то перестраивать в уже имеющейся структуре проекта, которое было насколько это возможно меньше и проще (а кому не хочется?) — просто работающий черный ящик. По этой причине мне, например, не понравилось это решение, здесь товарищ предлагает использовать его viewController как root, и устанавливать навигацию в таком стиле:

 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]; 

Да и реализация у него занимает ~ 400 строк, все это не может не расстраивать.

Сначала о том, как я сам это реализовывал до этого:

Код

    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));   // Остальной код создания анимации для показа   // … 

Мягко говоря не самое элегантное решение, накладывает свои ограничения, плюс еще возня с тем как новый контроллер потом убирать. Почему я сразу не использовал UIViewControllerAnimatedTransitioning? Честно сказать уже и не помню, может по началу и начал с ним делать, но столкнувшись с трудностью, о которой ниже, бросил и решил такой костыль лепить.

UIViewControllerAnimatedTransitioning

Об использовании этого протокола, который существует еще со времен iOS 7, не писал разве что ленивый. Есть сотни туториалов и статей. Прелесть в том, что сам протокол очень простой. Вам нужно реализовать всего 2 обязательных метода: transitionDuration: — в котором возвращается время анимации, и animateTransition: в котором сама анимация вьюКонтроллеров и происходит. Ничего проще, верно? Думал я. И вот метод анимации радостно написан:

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;     }]; 

Само перемещение модального окна происходило при помощи UIPercentDrivenInteractiveTransition. Вроде бы все работает, окно появляется, перемещается, закрывается. Но, все это было задумано, для того чтобы когда модальное окно внизу — можно было работать с предыдущим экраном, а предыдущий экран не отвечает на нажатия! Это стало вторым из недавних разочарований, после новости о закрытии Parse. Самым логичным мне показалось добавить экран fromtVC в containerView при открытии. И это работало — предыдущий экран был активен, правда теперь при закрытии оставался вообще только черный экран.

image

Почитав документацию и stackOverflow стало понятно что добавлять в контейнер fromVC ни в коем случае нельзя, но что же было делать — понятно тоже не было. Описав свою проблему я задал вопрос на SO, я даже задал вопрос на toster’e, но ответа все не было.
Я вдруг понял что не до конца осознаю вообще весь механизм метода animateTransition:. То есть, есть некий объект
containerView, на него добавляется открываемый контроллер, но что он собой представляет, какое место занимает в иерархии видов (view hierarchy), что при этом происходит с предыдущим контроллером? Я был уверен что ответив на эти вопросы я найду решение (спойлер — и не ошибся). Я сделал просто:

containerView.backgroundColor = [UIColor yellowColor]; 
До

После

Стало понятно что containerView — это обычное прозрачное UIView, добавляемое над предыдущим видом, а под ним мирно лежит fromVC. Значит, взаимодействовать с ним мешает этот самый контейнер, сдвинуть его не вариант, значит нужно как то «нажимать» сквозь него. Самый простой способ заставить UIView передавать нажатия «сквозь» себя — это выставить у него

userInteractionEnabled = NO; 

, но тогда это распространится на все его subview, что тоже не выход.

Responder Chain

Если вы раньше с этим не сталкивались, то позвольте вас познакомить с Responder Chain. Если в вкратце, то Responder Chain — это механизм iOS, который отвечает за передачу события (event), например нажатия, соответствующему объекту. Событие «путешествует» по этой цепочке, пока не дойдет до объекта, который сможет принять и обработать его. В случае нажатия, объект UIWindow сперва старается доставить событие тому view, где нажатия произошло. Это view известно как «hit-test view», а процесс поиска этого hit-test view называется hit-testing. Hit-testing предполагает проверку того что нажатие произошло в пределах подходящего view, а затем рекурсивно проверяет все его subview. Самое низкоуровневое view в это иерархии, находящееся в пределах нажатия, и становится hit-test view, после этого iOS передает событие этому view для обработки

Отличная иллюстрация этого процесса из документации:

Предположим пользователь нажал на view E. iOS находит hit-test view проверяя subview в таком порядке:
1. Нажатие в пределах view A, проверяем B и С.
2. Нажатие не в пределах B, а в пределах C, проверяем D и E.
3. Нажатие не пределах D, но в пределах E. E — самое низкоуровневое view в иерархии, содержащее координаты нажатия, так что оно и становится hit-test view


Зачем было все это повествование? А затем, что метод UIView — hitTest:withEvent: можно переписать!

Задача была следующая: сделать так, чтобы сквозь containerView можно было нажимать, и при этом чтобы нажатия на его subviews обрабатывались как обычно. Написать сабкласс и заставить containerView от него наследовать нельзя. Что то вроде:

MyUIViewSubclass *containerView = (MyUIViewSubclass *)[transitionContext containerView]; 

— не сработает. Значит нужно создать category (или как в русской литературе «категория продолжения класса»). «Стандартный» метод hitTest:withEvent: выглядит так:

- (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; } 

То есть, если мы куда то нажали, и в нашей цепочке (responder chain) попадается view с отключенным isUserInteractionEnabled или скрытое, или прозрачностью > 99% — возвращаем nil, этим самым мы говорим продолжить проверку, «пропустить сквозь себя» это нажатие. Если же иначе, мы пытаемся найти hitTest view и если оно найдено — вернуть его, что передаст событие нажатия этому view, или вернуть nil — и ничего не произойдет.
Теперь как сделать чтобы именно нажатие на контейнер не передавалось? Нужно как то различать именно containerView, самое простое — это просто выставить у него tag

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

Tag’ом я выбрал самое лучшее число 73: ).
А в методе hitTest:withEvent: добавляется дополнительное условие:

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

Таким образом нажатие никогда не «осядет» в containerView, а уйдет глубже по иерархии.

Итак, теперь все работает как задумывалось. Спасибо что дочитали, надеюсь вы узнали что то новое и интересное для себя.
Если Вас заинтересовало, то проект лежит на GitHub

Вы пользуетесь стандартным приложение Почта на iOS?

Никто ещё не голосовал. Воздержавшихся нет.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

ссылка на оригинал статьи https://habrahabr.ru/post/276799/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *