Куб для ваших сторис*
*как в Кое-что-грамме или Telegram.
В конце прошлого кода я затащил в Blink чекины (аналог историй с отметкой места), и передо мной встала задача красиво переключаться между пользователями. Все мы, конечно же, хотели анимацию куба. После пары дней ресёча, я пришел к неутешительному выводу, что вменяемых готовых реализаций для этого нет. Имеется парочка библиотек на GitHub, и одну из них я решил попробовать, потому что писать своё — времени не было.
Выбор пал на CubeContainerViewController-iOS. После переделок под нашу навигацию и стиль кода, казалось, что всё очень даже неплохо. Визуально всё работало, но это лишь на первый взгляд…
Первая версия куба через либу
Коротко, какие проблемы меня настигли:
Невозможность открытия куба с любой грани (открытие не первого в списке человека).
Скорость и углы анимации.
Необходимость держать в памяти все экраны.
Баги при быстром перелистовании по тапу.
Не совсем красиво.
Разные проблемы с логикой прочтения и сохранения стейта ранее прочитанных чекинов.
С этим решением мы прожили 3–4 месяца, параллельно наращивая функционал чекинов. Но пришло время довести это дело до ума.
Требования к новому кубу:
Стабильность работы.
Эффективность расходования памяти.
Гибкость настройки.
Удобное API.
Лучшим вариантом оказалась идея построить куб на UICollectionView. Так сразу решится проблема переиспользования экранов и добавится стабильность работы, ведь большую часть за нас будет делать коллекция.
Мы сверстали простую горизонтальную коллекцию с включенным пейджингом.
Hidden text
private let layout = Builder()
.minimumInteritemSpacing(0)
.minimumLineSpacing(0)
.sectionInset(.zero)
.scrollDirection(.horizontal)
.build()
private(set) lazy var containerView = Builder()
.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(HexagonContainerCell.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