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

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



Почитав документацию и 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, а уйдет глубже по иерархии.
Но есть большое НО. Делая таким образом, переопределяя стандартное поведения UIView, мы меняем это поведения абсолютно для всех UIView в программе, а не только для containerView — что делать нежелательно (спасибо хабраюзерам за указание на это в комментариях). Чтобы это исправить, можно воспользоваться рантаймом objective-C, а именно механизмом переключения реализаций методов (method swizzling).
Это потребует минимум изменений в коде:
1) в той же категории UIView добавим префикс к методу hitTest:WithEvent, например:
 - (UIView *)GI_hitTest:(CGPoint)point withEvent:(UIEvent *)event; 

2) После получения ссылки на containerView осуществляем переключение метода:
 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);
    });

Таким образом, переопределенный метод hitTest:WithEvent вызывается только для containerView, и не трогает другие UIView в системе.
Теперь все работает как задумывалось. Спасибо, что дочитали, надеюсь, вы узнали что то новое и интересное для себя.

Если Вас заинтересовало, то проект лежит на GitHub

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

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

  • 45,3%Да77
  • 32,4%Нет55
  • 22,4%У меня другая платформа38

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 13

    –1
    stackoverflow.com/a/5272612/1271424
    Нельзя забивать оригинальные методы категориями.
    Если так приспичило, то swizzling
      0
      Спасибо за ссылку, я думал об этом, но swizzling ведь тоже не лучший вариант, много читал о нем нехорошего. Что это сравни вуду и недокументированному хаку, а также что Swizzling has become a no-no and when being used will lead to rejection of an iOS app from iTunes
        +1
        Не знаю, что там насчет вуду и хаков, свиззлинг — вполне нормальное средство при соблюдении нескольких условий. Необходимо перекрывать только публичные методы, не изменять собственного состояния объекта и однократно вызывать оригинальный метод in any case.

        В отличие от этого подхода, перекрытие через категории — это undefined behavior.
          +2
          закрывать hitTest категорией и реализацией с какими-то домыслами о работе оригинального метода – вообще ужасный вариант
            0
            что вы подразумеваете под домыслами? Или в чем я не прав описывая работу метода hitTest?
              0
              если точный код hitTest нигде не опубликован, то я уверен, что вы неправы
              например, проверка на pointInside имхо бесполезна, потому что hitTest вызывается только тогда, когда в этой точке уже сработал pointInside
                0
                про pointInside был неправ, примерная реализация описывается в мануале про Responder Chain

                но мой аргумент всё равно в том, что написано там одно, а на самом деле там ещё чёрт знает что может быть, нельзя это брать и так легко трогать
            0
            О боже. Method swizzling это следствие всей прелести obj-c runtime и каждый разраб должен уметь готовить его с закрытыми глазами.
            Оверрайд с помощью категорий это UB.

            Т.е вместо того, чтобы поймать за шкирку контейнер вью и заменить класс в рантайме на нужный подкласс с таким пропускным поведением, Вы решили воспользоваться Undefined Behavior и поменять поведение hitTest во всех UIView.

            О какой черной коробке вообще может идти речь, если она модифицирует метод во всей системе?

            PS: Apple не банит и никогда не банила за использование функций obj-c runtime, откуда Вы это взяли.
              0
              Вы правы, я сделал глупость забив стандартную реализацию метода для всех UIView. Отличная метафора про шкирку контейнера )
          –3
          [Obective-C setEnable:NO];
          swift.enable = true
            0
            Я так и не понял – у вас в нижнем состоянии transition считается ещё не завершенным? Откуда там container view остаётся?
              0
              Я отказался от использования методов UIPercentDrivenInteractiveTransition по ряду причин, основной причиной было то, что если использовать cancelTransition: во время свайпа вниз, то это не позволит работать с навигацией вне модального окна, то есть не позволит ничего открыть\перейти в fromVC. Для перемещения модального окна я использую UIPanGestureRecognizer.
                0
                может стоило хотя бы заюзать `UIViewTransitioningDelegate` и `presentationControllerForPresentedViewController:presentingViewController:sourceViewController:`? чтобы там создать свой containerView и испортить pointInside только в нём

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

            Самое читаемое