Cube for your stories*
*as in Something-gram or Telegram.
At the end of the previous code, I dragged check-ins (analogous to stories with a place mark) into Blink, and I was faced with the task of switching between users beautifully. Of course, we all wanted a cube animation. After a couple of days of research, I came to the disappointing conclusion that there are no sensible ready-made implementations for this. There are a couple of libraries on GitHub, and I decided to try one of them, because I didn’t have time to write my own.
The choice fell on CubeContainerViewController-iOS. After reworking it to fit our navigation and code style, it seemed like everything was pretty good. Visually, everything worked, but that was only at first glance…
Briefly, what problems have befallen me:
It is impossible to open the cube from any side (opening is not possible for the first person on the list).
Animation speed and angles.
The need to keep all screens in mind.
Bugs when quickly scrolling by tap.
Not very pretty.
Various problems with the logic of reading and saving the state of previously read check-ins.
We lived with this solution for 3-4 months, simultaneously expanding the functionality of check-ins. But the time has come to bring this matter to mind.
Requirements for the new cube:
Stability of work.
Memory efficiency.
Flexibility of customization.
Convenient API.
The best option turned out to be the idea of building a cube on UICollectionView. This will immediately solve the problem of screen reuse and add stability to the work, because the collection will do most of it for us.
We have created a simple horizontal collection with paging enabled.
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()
The collection cell only has a field with UIViewController and method applyTransform
which we will talk about a little later.
Hidden text
final class CubeContainerCell: BaseCollectionViewCell {
var viewController: UIViewController?
override func initSetup() {
super.initSetup()
clipsToBounds = false
contentView.clipsToBounds = false
}
func applyTransform(_ percent: CGFloat) {
...
}
}
IN UICollectionViewDataSource everything is also standard, but we call loadViewIfNeeded
our controller has it inside the cell.
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
}
}
IN UICollectionViewDelegate We track the methods of showing and hiding cells from the screen for adding and removing 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)
}
}
And in UICollectionViewDelegateFlowLayout we stretch our cell to fill the entire screen.
Hidden text
extension CubeTransitionViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAt: IndexPath) -> CGSize {
collectionView.frame.size
}
}
All beauty begins in UIScrollViewDelegate. First of all, we need to track the scroll inside the method scrollViewDidScroll
and to transform our cells. For better User Experience, we turn off interaction in scrollView in the method scrollViewWillBeginDecelerating
and include in methods scrollViewDidEndDecelerating
And 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)
}
}
}
Next, we do a little math and transform the cell itself using the method 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)
}
Ta-daa-am! You and your cube are beautiful! How beautiful it looks now:
There will be no source code, you will have to do it manually. For all questions, write in the comments or to me in Telegram: t.me/zloysergunya