...

воскресенье, 1 июля 2018 г.

[Из песочницы] Пишем UI Snapchat'а на Swift'е

Пролог

В одном из моих проектов потребовалось сделать интерфейс наподобие такового в Снепчате. Когда выезжает карточка с информацией поверх изображения с камеры, плавно его заменяя на сплошной цвет, и точно также в обратную сторону. Меня лично особо заворожил переход из окна камеры в боковую карточку, и я с огромным удовольствием отправился ресерчить способы решения данной задачи.

Слева — пример Снепчата, справа — пример приложения, созданием которого мы займемся.

Вероятно, первое решение, приходящее в голову – адаптировать UIScrollView, расположить как-то вьюхи на нем, использовать паджинацию, но, откровенно говоря, скролл придуман для решения абсолютно иных задач, подцепить на него дополнительные анимации трудоемко, да и он не обладает необходимой гибкостью настройки. Поэтому использование его для решения данной проблемы – абсолютно не обосновано.

Скролл между окном камеры и боковой вкладкой обманчив — это вовсе не скролл, это интерактивный переход между вьюхами, принадлежащими к разным контроллерам. Кнопки в нижней его части — это обыкновенные табы, нажатие на которые перекидывает нас между контроллерами.

Таким образом Снепчат использует свою собственную версию навигационного контроллера типа UITabBarController с кастомным интерактивными переходами.

UIKit включает в себя два варианта навигационных контроллера, позволяющих кастомизировать переходы – это UINavigationController и UITabBarController. Оба имеют в своих делегатах методы navigationController(_:interactionControllerFor:) и tabBarController(_:interactionControllerFor:) соответственно, которые нам позволяют использовать свою собственную интерактивную анимацию для перехода.

tabBarController(_:interactionControllerFor:)

navigationController(_:interactionControllerFor:)

Но я бы не хотел быть ограниченными реализацией UITabBarController или UINavigationController, тем более что мы не можем управлять их внутренней логикой. Поэтому я решил написать свой подобный контроллер, и сейчас хочу рассказать и показать что из этого получилось.


Постановка задачи

Сделать свой контроллер-контейнер, в котором можно будет переключаться между дочерними контроллерами, используя для переходов интерактивные анимации, применив стандартный механизм как в UITabBarController и UINavigationController. Этот стандартный механизм нам нужен, чтобы использовать уже написанные готовые анимации переходов типа UIViewControllerAnimatedTransitioning.


Подготовка проекта

Обычно я стараюсь выносить модули в отдельные фреймворки, для этого я создаю новый проект приложения, и добавляю туда дополнительный таргет Cocoa Touch Framework, и дальше раскидываю исходники в проекте по соответствующим таргетам. Таки образом у меня получается отдельный с фреймворк с тестовым приложением для отладки.

Создаем Single View App.

Product Name и будет нашим таргетом.

Нажимаем на +, чтобы добавить таргет.

Выбираем Cocoa Touch Framework.

Называем наш фреймворк соответствующим именем, Xcode автоматом выбирает проект для нашего таргета и предлагает подвязать бинарник сразу в приложение. Соглашаемся.

Нам не нужны будут дефолтные Main.storyboard и ViewController.swift, удаляем их.

Также не забываем удалить значение из Main Interface в таргете приложения на вкладке General.

Теперь идем в AppDelegate.swift и оставляем только метод application следующего содержания:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

    // Launch our master view controller
    let master = MasterViewController()
    window = UIWindow()
    window?.rootViewController = master
    window?.makeKeyAndVisible()

    return true
}

Здесь мы устанавливаем свой контроллер на главное место, чтобы он показывался после лаунчскрина.

Теперь создадим этот самый MasterViewController. Он будет относиться к приложению, поэтому важно выбрать правильный таргет при создании файла.

MasterViewController мы унаследуем от SnapchatNavigationController, который чуть позже реализуем во фреймворке. Не забываем указать import нашего фреймворка. Полный код контроллера здесь я не привожу, пропуски показаны многоточием ..., приложение я разместил на GitHub, там можно посмотреть все детали. В этом контроллере нас интересует только метод viewDidLoad(), который инициализирует фоновый контроллер с камерой + один прозрачный контроллер (главное окно) + контроллер, содержащий вылетающую карточку.

import MakingSnapchatNavigation

class MasterViewController: SnapchatNavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // Устанавливаем фон
        let camera = CameraViewController()
        setBackground(vc: camera)

        // Создаем массив дочерних контроллеров
        var vcs: [UIViewController] = []

        // Первый прозрачный контроллер
        var stub = UIViewController()
        stub.view.backgroundColor = .clear
        vcs.append(stub)

        // Второй контроллер, на него добавляем скролл
        stub = UIViewController()
        stub.view.backgroundColor = .clear

        // Создаем скролл
        let scroll = UIScrollView()
        stub.view.addSubview(scroll)

        //Конфигурируем скролл
        ...

        // Создаем вьюху, которая будет лежать на скролле
        let content = GradientView()

        //Конфигурируем ее
        ...

        // Добавляем на скролл
        scroll.addSubview(content)
        vcs.append(stub)

        // Сеттим вьюхи в наш контроллер-контейнер
        setViewControllers(vcs: vcs)

    }

}

Что здесь происходит? Мы создаем контроллер с камерой и сеттим его на фон методом setBackground из SnapchatNavigationController. Этот контроллер содержит растянутое на всю вьюху изображение с камеры. Затем создаем пустой прозрачный контроллер и добавляем его в массив, он просто пропускает через себя изображение с камеры, на нем можно будет расположить элементы управления, создаем еще один прозрачный контроллер, добавляем на него скролл, внутрь скролла добавляем вьюху с контентом, добавляем второй контроллер в массив и сеттим этот массив специальным методом setViewControllers из родительского SnapchatNavigationController.

Не забываем добавить запрос на использование камеры в Info.plist

<key>NSCameraUsageDescription</key>
<string>Need camera for background</string>

На этом тестовое приложение считаем готовым, и переходим к самому интересному — к реализации фреймворка.


Структура родительского контроллера

Для начала создадим пустой SnapchatNavigationController, важно выбрать для него правильный таргет. Если все было сделано правильно, то приложение должно собираться. Данное состояние проекта можно выгрузить по ссылке.

open class SnapchatNavigationController: UIViewController {

    override open func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    // MARK: - Public interface

    /// Sets view controllers.
    public func setViewControllers(vcs: [UIViewController]) {

    }

    /// Sets background view.
    public func setBackground(vc: UIViewController) {

    }

}

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

Задаем переменные для хранения массива дочерних контроллеров. Сейчас мы жестко задаем их требуемое количество — 2 штуки. В будущем можно будет расширить логику контроллера для использования с любым количеством контроллеров. Также мы задаем переменную для хранения текущего отображаемого контроллера.

private let requiredChildrenAmount = 2

// MARK: - View controllers

/// top child view controller
private var topViewController: UIViewController?

/// all children view controllers
private var children: [UIViewController] = []

Создаем вьюхи. Нам нужна одна вьюха под фон, одна вьюха с эффектом, который мы хотим наложить на фон при смене контроллера. Также у нас есть вьюха-контейнер для текущего дочернего контроллера и вьюха-индикатор, которая будет подсказывать пользователю как работать с навигацией.

// MARK: - Views

private let backgroundViewContainer = UIView()
private let backgroundBlurEffectView: UIVisualEffectView = {
    let backgroundBlurEffect = UIBlurEffect(style: UIBlurEffectStyle.light)
    let backgroundBlurEffectView = UIVisualEffectView(effect: backgroundBlurEffect)
    backgroundBlurEffectView.alpha = 0
    return backgroundBlurEffectView
}()

/// content view for children
private let contentViewContainer = UIView()

private let swipeIndicatorView = UIView()

В следующем блоке мы задаем две переменных, swipeAnimator отвечает за анимацию, swipeInteractor отвечает за интерактив (возможность контролировать ход анимации), его мы обязательно инициализируем во время загрузки контроллера, поэтому делаем force unwrap.

// MARK: - Animation and transition

private let swipeAnimator = AnimatedTransitioning()
private var swipeInteractor: CustomSwipeInteractor!

Также мы задаем трансформацию для индикатора. Индикатор мы смещаем на ширину контейнера + двойной сдвиг от края + ширину самого индикатора, чтобы индикатор оказался на противоположенном конце контейнера. Ширина контейнера станет известна в процессе работы приложения, поэтому переменную вычисляем на ходу.

// MARK: - Animation transforms

private var swipeIndicatorViewTransform: CGAffineTransform {
    get {
        return CGAffineTransform(translationX: -contentViewContainer.bounds.size.width + (swipeIndicatorViewXShift * 2) + swipeIndicatorViewWidth, y: 0)
    }
}

Во время загрузки контроллера мы назначаем для анимации self (ниже мы реализуем соответствующий протокол), инициализируем интерактор на основе нашей анимации, ход которой он будет контролировать. Также ему назначаем себя в качестве делегата. Делегат будет реагировать на начало жеста пользователя и либо запускать анимацию, либо отменять в зависимости от состояния контроллера. Затем добавляем все вьюхи на главную и вызываем setupViews(), устанавливающий констрейнты.

override open func viewDidLoad() {
    super.viewDidLoad()

    swipeAnimator.animation = self
    swipeInteractor = CustomSwipeInteractor(with: swipeAnimator)
    swipeInteractor.delegate = self

    view.addSubview(backgroundViewContainer)
    view.addSubview(backgroundBlurEffectView)
    view.addSubview(contentViewContainer)
    view.addSubview(swipeIndicatorView)

    setupViews()

}

Дальше мы переходим к логике установки и удаления дочерних контроллеров в контейнер. Здесь все просто как в документации Apple. Мы используем методы предписанные для такого рода операций.

addChildViewController(vc) — добавляем дочерний контроллер к текущему.

contentViewContainer.addSubview(vc.view) — добавляем вьюху контроллера в иерархию вьюх.

vc.view.frame = contentViewContainer.bounds — растягиваем вьюху на весь контейнер. Раз мы используем здесь фреймы вместо авто лейаута, нужно менять их размеры каждый раз при изменении размера контроллера, эту логику мы опустим и будем считать, что во время работы приложения размеры контейнера меняться не будут.

vc.didMove(toParentViewController: self) — ставим точку в операции добавления дочернего контроллера.

swipeInteractor.wireTo — подвязываем текущий контроллер на жесты пользователя. Позже мы разберем этот метод.

// MARK: - Private methods

private func addChild(vc: UIViewController) {

    addChildViewController(vc)
    contentViewContainer.addSubview(vc.view)
    vc.view.frame = contentViewContainer.bounds
    vc.didMove(toParentViewController: self)
    topViewController = vc

    let goignRight = children.index(of: topViewController!) == 0
    swipeInteractor.wireTo(viewController: topViewController!, edge: goignRight ? .right : .left)

}

private func removeChild(vc: UIViewController) {
    vc.willMove(toParentViewController: nil)
    vc.view.removeFromSuperview()
    vc.removeFromParentViewController()
    topViewController = nil
}

Есть еще два метода, код которых я здесь приводить не буду: setViewControllers и setBackground. В методе setViewControllers мы просто устанавливаем массив дочерних контроллеров в соответствующую переменную нашего контроллера и вызываем addChild, чтобы отобразить один из них на вьюхе. В методе setBackground мы делаем тоже самое, что и в addChild, только для фонового контроллера.


Логика анимации контроллера-контейнера

Итого, основа нашего родительского контроллера – это:


  • UIView, делящихся на два типа
    • Контейнеры
    • Обычные
  • Список дочерних UIViewController
  • Объект управляющий анимацией swipeAnimator типа AnimatedTransitioning
  • Объект управляющий интерактивным ходом анимации swipeInteractor типа CustomSwipeInteractor
  • Делегат интерактивного хода анимации
  • Реализация протокола анимации

Сейчас мы разберем два последних пункта, затем перейдем к реализации AnimatedTransitioning и CustomSwipeInteractor.


Делегат интерактивного хода анимации

Делегат состоит всего из одного метода panGestureDidStart(rightToLeftSwipe: Bool) -> Bool, сообщающего контроллеру о начале жеста и его направлении. В ответ он ждет информацию о том, можно ли считать анимацию начавшейся.

В делегате мы проверяем текущий порядок контроллеров, чтобы понять, можем ли мы начать анимацию в заданном направлении, и если все ок, мы запускаем метод transition, с параметрами: контроллер из которого мы переходим, контроллер в который мы переходим, направление движения, флаг интерактивности (в случае false запускается фиксированная по времени анимация перехода).

func panGestureDidStart(rightToLeftSwipe: Bool) -> Bool {
    guard let topViewController = topViewController,
        let fromIndex = children.index(of: topViewController) else {
            return false
    }
    let newIndex = rightToLeftSwipe ? 1 : 0
    // избыточная проверка - задел на будущее
    if newIndex > -1 && newIndex < children.count && newIndex != fromIndex {
        transition(from: children[fromIndex], to: children[newIndex], goingRight: rightToLeftSwipe, interactive: true)
        return true
    }
    return false
}

Рассмотрим сразу тело метода transition. В первую очередь мы создаем контекст выполнения анимации CustomControllerContext. Этот класс мы также разберем чуть позже, он реализует протокол UIViewControllerContextTransitioning. В случае с UINavigationController и UITabBarController экземпляр реализации этого протокола создается системой автоматически и его логика от нас скрыта, нам нужно создать свою.

let ctx = CustomControllerContext(fromViewController: from,
                                  toViewController: to,
                                  containerView: contentViewContainer,
                                  goingRight: goingRight)
ctx.isAnimated = true
ctx.isInteractive = interactive

ctx.completionBlock = {
    (didComplete: Bool) in
    if didComplete {
        self.removeChild(vc: from)
        self.addChild(vc: to)
    }
};

Затем мы просто вызываем либо фиксированную, либо интерактивную анимацию. Фиксированную можно будет повесить в будущем на кнопки-табы навигации между контроллерами, в данном примере мы этого делать не будем.

if interactive {
    // Animate with interaction
    swipeInteractor.startInteractiveTransition(ctx)
} else {
    // Animate without interaction
    swipeAnimator.animateTransition(using: ctx)
}

Протокол анимации

Протокол анимации TransitionAnimation состоит из 4 методов:

addTo — метод, призванный создать правильную структуру дочерних вьюх в контейнере, так чтобы перекрытие предыдущей вьюхи новой происходило по задумке анимации.

/// Setup the views hirearchy for animation.
func addTo(containerView: UIView, fromView: UIView, toView: UIView, fromLeft: Bool)

prepare — метод, вызываемый перед анимацией для подготовки вьюх.

/// Setup the views position prior to the animation start.
func prepare(fromView from: UIView?, toView to: UIView?, fromLeft: Bool)

animation — сама анимация.

/// The animation.
func animation(fromView from: UIView?, toView to: UIView?, fromLeft: Bool)

finalize — необходимые действия после завершения анимации.

/// Cleanup the views position after the animation ended.
func finalize(completed: Bool, fromView from: UIView?, toView to: UIView?, fromLeft: Bool)

Используемую реализацию мы рассматривать не будем, там все достаточно прозрачно, перейдем сразу к трем основным классам, благодаря которым анимация происходит.


class CustomControllerContext: NSObject, UIViewControllerContextTransitioning

Контекст выполнения анимации. Для описания его функции обратимся к справке протокола UIViewControllerContextTransitioning:


A context object encapsulates information about the views and view controllers involved in the transition. It also contains details about the how to execute the transition.

Самое занимательное — это запрет на адаптацию данного протокола:


Do not adopt this protocol in your own classes, nor should you directly create objects that adopt this protocol.

Но нам он очень нужен для запуска стандартного механизма анимаций, поэтому мы все равно его адаптируем. В нем почти нет логики, он лишь хранит состояние. Поэтому даже не буду его здесь приводить. Его можно посмотреть на GitHub.

Он отлично работает на фиксированных по времени анимациях. Но при его использовании для интерактивных анимаций возникает одна проблема — UIPercentDrivenInteractiveTransition вызывает у контекста недокументированный метод. Единственным правильным решением в данной ситуации будет адаптировать еще один протокол — UIViewControllerInteractiveTransitioning для использования собственного контекста.


class PercentDrivenInteractiveTransition: NSObject, UIViewControllerInteractiveTransitioning

Вот оно — сердце проекта, позволяющее существовать интерактивным анимациям в кастомных контроллерах-контейнерах. Разберем его по порядку.

Инициализируется класс одним параметром типа UIViewControllerAnimatedTransitioning, это стандартный протокол анимации перехода между контроллерами. Таким образом мы сможем использовать любую из уже написанных анимаций вместе с нашим классом.

init(with animator: UIViewControllerAnimatedTransitioning) {
    self.animator = animator
}

Public interface у нас достаточно простой, четыре метода, функционал которых должен быть очевиден.

Стоит только отметить момент начала анимации, мы берем родительское вью контейнера и устанавливаем скорость слоя равной 0, таким образом мы добиваемся возможности контролировать ход выполнения анимации вручную.

// MARK: - Public

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
    self.transitionContext = transitionContext
    transitionContext.containerView.superview?.layer.speed = 0
    animator.animateTransition(using: transitionContext)
}

func updateInteractiveTransition(percentComplete: CGFloat) {
    setPercentComplete(percentComplete: (CGFloat(fmaxf(fminf(Float(percentComplete), 1), 0))))
}

func cancelInteractiveTransition() {
    transitionContext?.cancelInteractiveTransition()
    completeTransition()
}
func finishInteractiveTransition() {
    transitionContext?.finishInteractiveTransition()
    completeTransition()
}

Перейдем теперь к private блоку логики нашего класса.

setPercentComplete устанавливает временное смещение прогресса анимации для слоя супервью, рассчитывая значение из процентного значения завершенности и продолжительности анимации.

private func setPercentComplete(percentComplete: CGFloat) {
    setTimeOffset(timeOffset: TimeInterval(percentComplete) * duration)
    transitionContext?.updateInteractiveTransition(percentComplete)
}

private func setTimeOffset(timeOffset: TimeInterval) {
    transitionContext?.containerView.superview?.layer.timeOffset = timeOffset
}

completeTransition вызывается в момент, когда пользователь прекратил свой жест. Здесь мы создаем экземпляр класса CADisplayLink, который позволит нам в автоматическом режиме красиво завершить анимацию с точки когда пользователь больше не управляет ее ходом. Мы добавляем наш displayLink в run loop затем, чтобы система вызывала наш селектор всякий раз когда ей нужно отобразить новый кадр на экране девайса.

private func completeTransition() {
    displayLink = CADisplayLink(target: self, selector: #selector(tickAnimation))
    displayLink!.add(to: .main, forMode: .commonModes)
}

В нашем селекторе мы вычисляем и устанавливаем временное смещение прогресса анимации как это делали раньше во время жеста пользователя, либо завершаем выполнение анимации при достижении ее начальной или финальной точки.

@objc private func tickAnimation() {

    var timeOffset = self.timeOffset()
    let tick = (displayLink?.duration ?? 0) * TimeInterval(completionSpeed)
    timeOffset += (transitionContext?.transitionWasCancelled ?? false) ? -tick : tick;

    if (timeOffset < 0 || timeOffset > duration) {
        transitionFinished()
    } else {
        setTimeOffset(timeOffset: timeOffset)
    }
}

private func timeOffset() -> TimeInterval {
    return transitionContext?.containerView.superview?.layer.timeOffset ?? 0
}

Завершая анимацию, мы отключаем наш displayLink, возвращаем скорость слоя, и, если анимация не была отменена, то есть достигла своего финального кадра, вычисляем время с которого анимация слоя должна начаться. Подробнее об этом можно узнать в Core Animation Programming Guide, или вот в этом ответе на stackoverflow.

private func transitionFinished() {
    displayLink?.invalidate()

    guard let layer = transitionContext?.containerView.superview?.layer else {
        return
    }
    layer.speed = 1;

    let wasNotCanceled = !(transitionContext?.transitionWasCancelled ?? false)
    if (wasNotCanceled) {
        let pausedTime = layer.timeOffset
        layer.timeOffset = 0.0;
        let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
        layer.beginTime = timeSincePause
    }

    animator.animationEnded?(wasNotCanceled)
}

class AnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning

Последний класс, который мы еще не разобрали — реализация протокола UIViewControllerAnimatedTransitioning, в которой мы управляем порядком выполнения методов протокола нашей анимации addTo, prepare, animation, finalize. Здесь все достаточно прозаично, стоит отметить только использование UIViewPropertyAnimator для выполнения анимации вместо более типичного UIView.animate(withDuration:animations:). Это сделано для того, чтобы была возможность дополнительно управлять ходом анимации, и в случае ее отмены, вернуть ее в начальную позицию вызовом finishAnimation(at: .start), что позволяет избежать ненужного моргания финального кадра анимации на экране.


Эпилог

Мы создали рабочую демку интерфейса подобного интерфейсу программы Snapchat. В своей версии я сконфигурировал констрейнты таким образом, чтобы справа и слева от карточки оставались поля, кроме того я оставил работать камеру на фоновой вьюхе, чтобы создать эффект позади карточки. Это сделано исключительно для демонстрации возможностей данного подхода, как это будет влиять на производительность девайса и заряд его батареи я не проверял.

Эта статья — моя первая проба пера в жанре технической литературы, я мог упустить какие-то важные моменты, поэтому я готов с радостью ответить на вопросы в комментариях. Спасибо всем кто прочитал мою статью, надеюсь вы нашли что-то полезное для себя здесь.

Готовый проект можно загрузить из GitHub по ссылке.

Еще раз спасибо, всем хорошего дня, интересных задач, продуктивного коддинга!


Источники информации

Для написания этой программы я использовал следующую информацию:


  1. Статья Custom Container View Controller Transitions, автор Joachim Bondo.

    Автор статьи предложил вариант кастомного контекста на Objective C. Я использовал его вариант для написания своего класса на Swift.

    Ссылка


  2. Статья Interactive Custom Container View Controller Transitions, автор Alek Åström

    Автор продолжил работу из предыдущей статьи и предложил свой подход в создании интерактивного варианта анимации, также на Objective C, также я использовал его код для написания своего класса на Swift.

    Ссылка


  3. SwipeableTabBarController

    Проект, в котором автор подключил различные интерактивные анимации для переходов в стандартном UITabBarController. Использовал некоторые идеи из кода проекта.

    Ссылка.


Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий