*как в Кое-что-грамме или Telegram.

В конце прошлого кода я затащил в Blink чекины (аналог историй с отметкой места), и передо мной встала задача красиво переключаться между пользователями. Все мы хотели анимацию куба. После пары дней ресёча, я пришел к неутешительному выводу, что вменяемых готовых реализаций для этого нет. Имеется парочка библиотек на GitHub, и одну из них я решил попробовать, потому что писать своё — времени не было.

Выбор пал на CubeContainerViewController‑iOS. После переделок под нашу навигацию и стиль кода, казалось, что всё очень даже неплохо. Визуально всё работало, но это лишь на первый взгляд...

Первая версия куба через либу
Первая версия куба через либу

Коротко, какие проблемы меня настигли:

  1. Невозможность открытия куба с любой грани (открытие не первого в списке человека).

  2. Скорость и углы анимации.

  3. Необходимость держать в памяти все экраны.

  4. Баги при быстром перелистовании по тапу.

  5. Не совсем красиво.

  6. Разные проблемы с логикой прочтения и сохранения стейта ранее прочитанных чекинов.


С этим решением мы прожили 3-4 месяца, параллельно наращивая функционал чекинов. Но пришло время довести это дело до ума.

Требования к новому кубу:

  1. Стабильность работы.

  2. Эффективность расходования памяти.

  3. Гибкость настройки.

  4. Удобное API.

Лучшим вариантом оказалась идея построить куб на UICollectionView. Так сразу решится проблема переиспользования экранов и добавится стабильность работы, ведь большую часть за нас будет делать коллекция.

Мы сверстали простую горизонтальную коллекцию с включенным пейджингом.

Hidden text
private let layout = Builder<UICollectionViewFlowLayout>()
    .minimumInteritemSpacing(0)
    .minimumLineSpacing(0)
    .sectionInset(.zero)
    .scrollDirection(.horizontal)
    .build()
    
private(set) lazy var containerView = Builder<BaseCollectionView>()
    .showsHorizontalScrollIndicator(false)
    .showsVerticalScrollIndicator(false)
    .collectionViewLayout(layout)
    .isPagingEnabled(true)
    .bounces(true)
    .backgroundColor(.clear)
    .build()

В ячейке коллекции есть только поле с UIViewController и метод applyTransform, про который поговорим чуть позже.

Hidden text
final class CubeContainerCell: BaseCollectionViewCell {
    var viewController: UIViewController?
    
    override func initSetup() {
        super.initSetup()
        
        clipsToBounds = false
        contentView.clipsToBounds = false
    }
    
    func applyTransform(_ percent: CGFloat) {
        ...
    }
}

В UICollectionViewDataSource всё также стандартно, но вызываем loadViewIfNeeded у нашего контроллера внутри ячейки. Это необходимо для того, чтобы вьюшка нашего контроллера загрузилась. Обычно мы не используем этот метод, потому что запрос на отображения контроллера уже является причиной для загрузки вьюшки. Здесь мы ничего не презентуем и нам нужно вызвать это вручную.

Hidden text
extension CubeTransitionViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { accounts.count }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard
            let cell = collectionView.dequeue(CubeContainerCell.self, for: indexPath),
            let context = self.accounts.at(indexPath.item)
        else { fatalError("wrong index") }
        
        cell.viewController = try! userCheckinsFactory.build(with: context)
        cell.viewController?.loadViewIfNeeded()
        
        return cell
    }
}

В UICollectionViewDelegate отслеживаем методы показа и скрытия ячеек с экрана для добавления и удаления child.

Hidden text
extension CubeTransitionViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard let cell = cell as? CubeContainerCell, let viewController = cell.viewController else { return }
        self.addChild(vc: viewController, bindedTo: cell.contentView)
    }
    
    func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard let cell = cell as? CubeContainerCell, let viewController = cell.viewController else { return }
        self.removeChild(viewController)
    }
}

А в UICollectionViewDelegateFlowLayout растягиваем нашу ячейку на весь экран.

Hidden text
extension CubeTransitionViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAt: IndexPath) -> CGSize {
        collectionView.frame.size
    }
}

Вся красота начинается в UIScrollViewDelegate. В первую очередь, нам необходимо отслеживать скролл внутри метода scrollViewDidScroll и производить трансформацию наших ячеек. Для лучшего User Experience, мы выключаем интеракцию у scrollView в методе scrollViewWillBeginDecelerating и включаем в методах scrollViewDidEndDecelerating и scrollViewDidEndScrollingAnimation

Hidden text
extension CubeTransitionViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        transformViewsInScrollView(scrollView)
    }
    
    func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
        scrollView.isUserInteractionEnabled = false
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        scrollView.isUserInteractionEnabled = true
    }
    
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        scrollView.isUserInteractionEnabled = true
    }
    
    func transformViewsInScrollView(_ scrollView: UIScrollView) {
        let svWidth = scrollView.frame.width
        
        for index in 0 ..< mainView.containerView.visibleCells.count {
            guard let view = mainView.containerView.visibleCells[index] as? CubeContainerCell else { continue }
            
            let svCenter = scrollView.frame(in: view).center.x
            let cellCenter = view.frame(in: view).center.x
            
            let xDiff = svCenter - cellCenter
            
            view.applyTransform(xDiff / svWidth)
        }
    }
}

Далее немного колдуем с математикой и трансформируем саму ячейку с помощью метода applyTransform

Hidden text
func applyTransform(_ percent: CGFloat) {
    let view = self.contentView
        
    let maxAngle: CGFloat = 60.0
    let rad = percent * maxAngle * CGFloat(Double.pi / 180)
        
    var transform = CATransform3DIdentity
    transform.m34 = 1 / 500
    transform = CATransform3DRotate(transform, rad, 0, 1, 0)
        
    view.layer.transform = transform
        
    let anchorPoint = percent > 0 ? CGPoint(x: 1, y: 0.5) : CGPoint(x: 0, y: 0.5)
        
    var newPoint = CGPoint(
        x: view.bounds.size.width * anchorPoint.x,
        y: view.bounds.size.height * anchorPoint.y
    )
    var oldPoint = CGPoint(
        x: view.bounds.size.width * view.layer.anchorPoint.x,
        y: view.bounds.size.height * view.layer.anchorPoint.y
    )
        
    newPoint = newPoint.applying(view.transform)
    oldPoint = oldPoint.applying(view.transform)
        
    var position = view.layer.position
    position.x -= oldPoint.x
    position.x += newPoint.x
        
    position.y -= oldPoint.y
    position.y += newPoint.y
        
    view.layer.position = position
    view.layer.anchorPoint = anchorPoint
        
    view.alpha = 1 - (-percent).clamped(0, 1)
}

Та-даа-ам! Вы и ваш куб прекрасны! Как красиво теперь это выглядит:

Финальный вид куба
Финальный вид куба

Исходников не будет, придётся поработать ручками. По всем вопросам пишите в комментарии или мне в Telegram.

Комментарии (11)


  1. achekalin
    13.07.2024 21:31

    Не в обиду, но, когда я читаю про очередной недо-инста-грамм и овер-теле-грамм, я хочу сказать фразу из старого анекдота "гасите свет, они лезут на свет!"

    Ну вот куда еще один убийца других убийц? А если не убийца - то откуда возьмется аудитория?


    1. sergeykotov Автор
      13.07.2024 21:31

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


      1. arman_ka
        13.07.2024 21:31

        мб есть смысл кратко в начале написать, что такое blink и какая там аудитория уже.


        1. sergeykotov Автор
          13.07.2024 21:31

          Спасибо, допишу


    1. sergeykotov Автор
      13.07.2024 21:31

      Да и юзеров у нас уже предостаточно


    1. arman_ka
      13.07.2024 21:31
      +1

      очень странно писать такой коммент, даже не попытавшись понять, что это за приложение


    1. bogdanoleinik
      13.07.2024 21:31

      А где вы увидели слова автора о желании заменить телеграмм или инсту?) Смешной такой)


  1. dopusteam
    13.07.2024 21:31
    +1

    Все мы, конечно же, хотели анимацию куба.

    А почему 'конечно же'?


    1. sergeykotov Автор
      13.07.2024 21:31

      Стандарт, так заведено у всех аналогов фичи


      1. arman_ka
        13.07.2024 21:31
        +1

        у вк слайды, вродь и так норм, кажется, что вообще на это не обращаешь внимание


        1. sergeykotov Автор
          13.07.2024 21:31

          Хороший поинт, уберу этот акцент, чтобы не бросалось в глаза