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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/828680/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *