Реализуем свой dropDown ViewController (aka iOS 8 Mail app) в 200 строк
Еще с beta версии iOS 8 мне очень понравилась эта новая фича приложения почты: при создании нового письма можно просто смахнуть это окно вниз и продолжить работу на предыдущем экране. Не уверен насколько эта фича оказалась полезной конкретно в этом приложении, но идея то отличная! В тот же вечер я сел делать подобную штуку, и таки сделал свой велосипед, и на время забыл об этом.
Недавно мне понадобился похожий функционал. Не захотев брать свое старое решение, и не найдя готовой реализации, которая бы мне понравилось, было решено написать свое. Что из этого получилось, с какими трудностями пришлось столкнуться, и что нового было вынесено — под катом.
Какого решения мне хотелось? Такого, из за которого не придется что то перестраивать в уже имеющейся структуре проекта, которое было насколько это возможно меньше и проще (а кому не хочется?) — просто работающий черный ящик. По этой причине мне, например, не понравилось это решение, здесь товарищ предлагает использовать его 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: в котором сама анимация вьюКонтроллеров и происходит. Ничего проще, верно? Думал я. И вот метод анимации радостно написан:
- (void)animateTransition:(id )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 при открытии. И это работало — предыдущий экран был активен, правда теперь при закрытии оставался вообще только черный экран.
Почитав документацию и 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
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.