*как в Кое-что-грамме или Telegram.
В конце прошлого кода я затащил в Blink чекины (аналог историй с отметкой места), и передо мной встала задача красиво переключаться между пользователями. Все мы хотели анимацию куба. После пары дней ресёча, я пришел к неутешительному выводу, что вменяемых готовых реализаций для этого нет. Имеется парочка библиотек на GitHub, и одну из них я решил попробовать, потому что писать своё — времени не было.
Выбор пал на CubeContainerViewController‑iOS. После переделок под нашу навигацию и стиль кода, казалось, что всё очень даже неплохо. Визуально всё работало, но это лишь на первый взгляд...
Коротко, какие проблемы меня настигли:
Невозможность открытия куба с любой грани (открытие не первого в списке человека).
Скорость и углы анимации.
Необходимость держать в памяти все экраны.
Баги при быстром перелистовании по тапу.
Не совсем красиво.
Разные проблемы с логикой прочтения и сохранения стейта ранее прочитанных чекинов.
С этим решением мы прожили 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.