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…

First version of cube via lib

First version of cube via lib

Briefly, what problems have befallen me:

  1. It is impossible to open the cube from any side (opening is not possible for the first person on the list).

  2. Animation speed and angles.

  3. The need to keep all screens in mind.

  4. Bugs when quickly scrolling by tap.

  5. Not very pretty.

  6. 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:

  1. Stability of work.

  2. Memory efficiency.

  3. Flexibility of customization.

  4. 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 applyTransformwhich 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:

The final view of the cube

The final view of the cube

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

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *