Recreating the Instagram Stories Cube Animation

2006 Views

In this tutorial I'll be going over how to recreate the Instagram Stories cube animation. This is a preview of what we'll be building:

Look fun? Great, let's begin!

Subclassing UIScrollView

Create a new Xcode project, and choose Single Page Application as the template.

The first thing we will do is create a new class that subclasses UIScrollView, called StoriesScrollView. Add the stories property, and override the init method as follows:

class StoriesScrollView: UIScrollView {
    var stories = [UIView]()

    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.clear
        isPagingEnabled = true
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

The stories will simply represent the data source of our scrollview, and its length will determine our scrollview's content width. We must set isPagingEnabledto true as well.

We also need a method to add stories to our data source. For that, create the following function (still in the StoriesScrollView class):

func setDataSource(with stories: [UIView]) {
    self.stories = stories
    for i in 0..<self.stories.count {
        let story = self.stories[i]
        let width = frame.width
        let height = frame.height
        let xOffset = width * CGFloat(i)
        story.frame = CGRect(x: xOffset, y: 0, width: width, height: height)
        addSubview(story)
        contentSize = CGSize(width: xOffset + width, height: height)
    }
}

In this method, we are simply looping through each story and adding it as a subview to our scrollview, and then setting the contentSize accordingly.

Next, in ViewController.swift, add an instance of our scrollView:

class ViewController: UIViewController {
    var scrollView = StoriesScrollView()
}

Then, still in ViewController.swift, create the following function to layout the srollview:

func layout() {
    scrollView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
    scrollView.delegate = self
    view.addSubview(scrollView)

    let story1 = UIView()
    story1.backgroundColor = UIColor.orange
    let story2 = UIView()
    story2.backgroundColor = UIColor.red
    let story3 = UIView()
    story3.backgroundColor = UIColor.blue
    let story4 = UIView()
    story4.backgroundColor = UIColor.green
    scrollView.setDataSource(with: [story1, story2, story3, story4])
}

Here we are just simply adding the scrollView as a subview to our main view controller's view, and then setting its data source. But right now it won't compile--because our ViewControler does not conform to UIScrollViewDelegate. Just add the following extension to fix this:

extension ViewController: UIScrollViewDelegate { }

Then, call the layout function in your viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()
    layout()
}

Build and run. You should see just a typical scrollView where you can view the different views and their background colors. Great! But, where's the cube animation?

Setting Up the Cube Animation

We will be doing all of our work on the animating part in the scrollViewDidScroll delegate method. But first, let's go over the strategy used to approach this kind of problem:

If we analyze this video, we can tell a few things:

  • At any given time, there are 2 views visible on the screen
  • As we swipe our finger right to left (increasing the content offset x value), the view the furthest to the left of the screen (the cat in the video) is rotating towards an angle of 90°.
  • As we swipe our finger from left to right (decreasing the content offset x value), the view the furthest to the right is rotating towards an angle of -90°.
  • When the view is centered and visible on our screen, it is at an angle of 0°.

From these observations, we can deduce:

  • The view furthest to the left on the screen should oscillate between 0 and 90 degrees.
  • The view furthest to the right should oscillate between 0 and -90°.

So with that in mind, let's start by adding the following implementation of scrollViewDidScroll under the UIScrollViewDelegate extension:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let visibleViews = self.scrollView.visibleViews().sorted(by: {$0.frame.origin.x > $1.frame.origin.x} ) // 1
    let xOffset = scrollView.contentOffset.x // 2

    // 3
    let rightViewAnchorPoint = CGPoint(x: 0, y: 0.5)
    let leftViewAnchorPoint = CGPoint(x: 1, y: 0.5)

    // 4
    var transform = CATransform3DIdentity
    transform.m34 = 1.0 / 1000

    // 5
    let leftSideOriginalTransform = CGFloat(90 * Double.pi / 180.0)
    let rightSideCellOriginalTransform = -CGFloat(90 * Double.pi / 180.0)
}

1: As mentioned before, we'll be dealing with each of the views that are visible on the screen at any given time. To do this, we'll create a method on our custom scrollview class, visibleViews(), and then sort them to put the view with the larger xOrigin first (so the first item in the array will be the view on the right side of the screen).

In 2, we create a variable to keep track of the current xOffset value. This will be used in angle caluclations.

In 3, we create our 2 anchor points. In order to rotate our views, we need to set their anchor point. When you rotate an object, it must be rotated around a certain point--for a view, this is its anchor point. The view on the right of the screen will have an X anchor point of 0. This is because we are starting its rotation in its upper left corner. For the view on the left side however, it's X anchor point will be 1. When we first start rotating, we rotate from the upper right corner of the screen. From the left view's perspective, this is its furthest x point. But from the right view's perspective, this is it's origin. That's why the right view's x anchor point is 0, and the left view's is 1.

At 4, we create our transform. The .m34 value dictates the user's perspective of the scene. Try setting this to a higher value, such as 1 / 200, and you'll notice that there is a lot more white space around the view when you scroll, because the view is set as if it's in the distance, and not closer to the user. For our purposes, any value between 1/500 - 1/1000 is fine.

5: These variables will be used to as a reference for the original states of a view on the left and right side of the screen, respectively

Next, let's add the visiableViews() method to the StoriesScrollView class:

func visibleViews() -> [UIView] {
    let visibleRect = CGRect(x: contentOffset.x, y: 0, width: frame.width, height: frame.height)
    var views = [UIView]()
    for view  in stories{
        if view.frame.intersects(visibleRect) { views.append(view) }
    }
    return views
}

This simply loops through all the subviews, and if the frame of the view intersects with the visible rect of the scroll view, we return that view in the array.

Completing the Cube Animation

If you build and run at this point, you still won't see any actual animation. Let's change that! Add the following code to the scrollViewDidScroll method:

if let viewFurthestRight = visibleViews.first, let viewFurthestLeft =  visibleViews.last { // 1
        let hasCompletedPaging = (xOffset / scrollView.frame.width).truncatingRemainder(dividingBy: 1) == 0 // 2
        var rightAnimationPercentComplete = hasCompletedPaging ? 0 :1 - (xOffset / scrollView.frame.width).truncatingRemainder(dividingBy: 1) // 3
        if xOffset < 0 { rightAnimationPercentComplete -= 1 } // 4
        viewFurthestRight.transform(to: rightSideCellOriginalTransform * rightAnimationPercentComplete, with: transform) // 5
        viewFurthestRight.setAnchorPoint(rightViewAnchorPoint) // 6

        if  xOffset > 0 { // 7
            let leftAnimationPercentComplete = (xOffset / scrollView.frame.width).truncatingRemainder(dividingBy: 1)
            viewFurthestLeft.transform(to: leftSideOriginalTransform * leftAnimationPercentComplete, with: transform)
            viewFurthestLeft.setAnchorPoint(leftViewAnchorPoint)
        }
    }

That's a lot of code! Let's go through it step by step:

At 1, we get the view furthest to the right, and the view furthest to the left.

At 2, we create a constant, hasCompletedPaging, which will tell us if the scrollview is currently stopped scrolling and is resting on a page. We know that the only time the value of (xOffset / scrollView.frame.width) will have a decimal value of 0 is when the view has paged, ie 1.0, 2.0, etc.

At 3, we calculate how far complete the animation for the view on the right side is. Keep in mind, as we swipe our finger right to left, the view on the right side of the screen is starting at -90, and it going towards 0°. If we have completed paging, then we simply set the angle to 0°. If we haven't, then we need to measure how close we are to completing our paging animation. This can be done using (xOffset / scrollView.frame.width).truncatingRemainder(dividingBy: 1). However, then, when the animation is nearly complete that value would be 0.9999. Idealy, we want the inverse--because we want to take this value and multiply it (as we do in 5) to cleanly get our new angle (which would be 0 when the paging is done). That's why we use the 1 - (xOffset / scrollView.frame.width).truncatingRemainder(dividingBy: 1) instead--this will give us a value of nearly 0, so its decrement mirrors that of the decrementing angle.

At 4--If the xOffset is less than 0, the (xOffset / scrollView.frame.width) will result in a negative number. Then when we do 1 - (xOffset / scrollView.frame.width).truncatingRemainder(dividingBy: 1), we'll get a number 1 higher than we need because we just subtracted a negative. To fix this, if the xOffset is less than 0, we just subtract 1 from it and everything works.

5--We mutliply the original angle by the measurement of how much the animation is complete. This extension on UIView hasn't been written yet, we will add that shortly.

6 Here we set the anchor point for the view. This is the point around which the view will rotate. We'll write this extension shortly as well.

At 7, we only perform this action when the content offset is greater than 0.

Below 7, we perform the same calculations, just for the view on the left side of the screen.

Add the extensions

Add the following extensions on UIView, so our project will compile :D

extension UIView {
  func transform(to radians: CGFloat, with transform: CATransform3D) {
        layer.transform = CATransform3DRotate(transform, radians, 0, 1, 0.0)
 }

    // method source: https://stackoverflow.com/questions/1968017/changing-my-calayers-anchorpoint-moves-the-view
 func setAnchorPoint(_ point: CGPoint) {
       var newPoint = CGPoint(x: bounds.size.width * point.x, y: bounds.size.height * point.y)
       var oldPoint = CGPoint(x: bounds.size.width * layer.anchorPoint.x, y: bounds.size.height * layer.anchorPoint.y)

        newPoint = newPoint.applying(transform)
        oldPoint = oldPoint.applying(transform)

        var position = layer.position
        position.x -= oldPoint.x
        position.x += newPoint.x

        position.y -= oldPoint.y
        position.y += newPoint.y

       layer.position = position
       layer.anchorPoint = point
    }
}

There are several ways to change the angles and transforms of UIViews, but for 3D transformations, such as what we're doing, we must use Core Animation (not Core Graphics' CGAffineTransform--that is only for 2D transformations). We also know that layers are animatable, not the view itself. With that knowledge, we know we have to call .transform on our view's underlying layer object.

When we change our layer's anchor point, we must also account for the transform we just applied, and change the layer's postion as well.

Build and run...it works!

Hope this tutorial was helpful, and leave any questions/comments below. You can find the completed code for this tutorial here. Have a good one!



Enjoy this article? Consider supporting VaporForums by following us on Twitter! Get the latest Vapor articles, tutorials, and news.