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

в 12:24, , рубрики: Cocoa, iOS разработка, transition, xcode, разработка под iOS

Еще с 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 при открытии. И это работало — предыдущий экран был активен, правда теперь при закрытии оставался вообще только черный экран.

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

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

containerView.backgroundColor = [UIColor yellowColor];
До

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

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

Стало понятно что 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 для обработки

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

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

Предположим пользователь нажал на 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

Автор: Dalein

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js