Pull to refresh

Куб для ваших сторис*

Level of difficultyMedium
Reading time5 min
Views1.1K

*как в Кое-что-грамме или 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.

Tags:
Hubs:
Total votes 6: ↑4 and ↓2+4
Comments11

Articles