Recreating Instagram’s Page Control

This is the first part of a small project I’ve had in mind for a while, a series of articles where I recreate nice UI details of famous apps, and explain step by step how it’s done. I plan on making one of these every two or three weeks.

Introduction

The Instagram app has a lot of nice UI details, one the always intrigued me is the page control, which shows a maximum of seven dots, and scrolls as you scroll between photos. As you can see in the gif below, it has three central dots that are full size, and up to two on each side that are smaller the further they are from the center. As you scroll through the pages, the dots scroll and change in size.

For this article I recreated that page control, and I’ll show you step by step how I did it. I strongly recommend you download the playground to follow along, since I won’t post all the code here and you might miss something.

The Playground contains several numerated pages, each page corresponds to the step with the same number in this article.

It also contains, in the Sources folder, a copy of my library CGMath, which extends CGGeometry types like CGRect, CGSize, etc., as well as FloatingPoint and Comparable, with some properties and methods useful for animations.

If you want to use it and have no interest in learning how it was created, you can install it with Cocoapods. More info here.

Step 1: Class Setup

We’ll start by creating a UIView subclass called PageControl, and set some properties. We’ll also override draw(_ rect: CGRect), since that’s what we’ll use to draw the dots.

Step 2: Draw Dots

To draw the dots we’ll need to calculate their position, once we have it we can use UIBezierPath to draw the dots with the correct color.

You might notice that the dots are not centered, instead they are stuck to the left of the view. We’ll fix that later.

Step 3: Update Dots

Since we added a property observer that calls setNeedsDisplay() to all variables, whenever any of those are changed, our view will be redrawn.

To trigger that, we’ll use the UIScrollViewDelegate to be notified whenever the scroll view ends a movement, and update our PageControl then.

That should do it, now our PageControl will update whenever the scroll view stops scrolling.

Step 4: Intrinsic Content Size

If you are not familiar with the intrinsicContentSize property of UIView, you can read about it in this article I wrote, but the gist of it is that the intrinsicContentSize property of a view is the size the view wants to be in order to show all of it’s content and no more.

UIView subclasses are in charge or returning their desired size, or .zero if that’s not possible, but we can easily calculate the required size for our view.

The height will be simply the size of the dot, the width will be the number of dots times their size, plus one less than the number of dots times the spacing. If you don’t understand why it’s one less for the spacing, count the spaces between your fingers.

To make sure our dots are centered, even if the view has a width different from it’s intrinsicContentSize (which would be the result of using a width constraint on the view), we’ll calculate a horizontalOffset, which is simply half the difference between the actual size and the desired size.

Finally, we’ll call invalidateIntrinsicContentSize() on pages.didSet, that way, whenever the number of pages changes, the size will be recalculated and the view’s size will be updated.

Step 5: Scrolling Dots

You might be thinking that this is pretty easy, and so far it has been, but this is the part when things get tricky, so hold on to your seat.

To scroll the dots, we’ll need to keep track of which central dot is highlighted, and how much we’ve scrolled the dots. We’ll use these two variables.

Now, in selectedPage.didSet, we’ll update the value of those variables like this.

This might be hard to understand at a glance, what it does is to calculate where selectedPage lands after being modified by the pageOffset, if it’s between 0 and 2, 0 being the left dot, 1 being the central dot, and 2 being the right dot, we’ll update centerOffset. If it falls outside those values, we’ll update pageOffset instead. That way selectedPage — pageOffset will fall in the range we want and the selected page will always be within the central dots.

Now we have to update the drawing code. We’ll start by modifying the way we calculate the horizontalOffset, for each offset we have we’ll move all the dots to the left the length of one dot and one spacing. The reason we add a 2 is that we have two dots on the left of the central three. If we’re on the leftmost page, with a pageOffset of 0, we’ll still want to move the dots to the right to make sure that the central dots are centered.

The other change you’ll notice is the scale variable, which will calculate the scale for each of the page dots. Remember that the central three are full size, and as they go farther away, they get smaller.

1 + pageOffset will tell us what is the index that is currently as the middle dot, we’ll use that to calculate the “distance” between that dot and our current page. If the distance is 0 or 1, we’ll return a scale of 1, as the distance grows bigger, we’ll return smaller numbers until we reach 0.

The 1 we add to pageOffset might seem like it comes out of nowhere, but it makes sense once you think that pageOffset tells us which page is currently on the left, central dot, if we add 1 to that, we get the index of the page in the center dot.

Finally, in intrinsicContentSize(), we’ll limit the number of pages to show to a maximum of seven.

Step 6: Animate Scroll

If you run the playground you’ll notice that while it’s easy to tell when the highlighted dot moves between the central dots, once it moves to one of the dots on the sides, it’s hard or impossible to tell that something happened.

To fix this, we’ll implement a small animation, just like Instagram does.

It’s totally possible to implement this animation using the current drawing method, but UIKit already provides us with some handy methods to animate UIViews, so we’ll switch our code to use that instead and save ourselves some time.

First we’ll create a dotViews property to hold our views. Whenever it changes we’ll remove the views in the old value and add the views in the new one, then we’ll update the colors and positions (we’ll look at these functions in a minute).

On pages.didSet, we’ll set the dotViews value to a number of views equal to the number of pages (CircularView is a UIView subclass that will round it’s corners automatically, you can find it’s declaration in the playground).

Next we’ll get rid of draw(_ rect: CGRect) and replace it with updateColors() and updatePositions(). Update colors is very straightforward:

updatePositions() is pretty much the same as draw(_ rect: CGRect), with the difference that instead of drawing each dot with the calculated values, we’ll update the corresponding view.

The final step is animating the change in pageOffset.didSet, since the that offset is what dictates when the dots should scroll. We can use UIView.animate to animate the position change.

Note: The animation block is wrapped inside an asyncAfter call to add a one frame delay to this animation, that way the user will see the dot change in color and then all the dots move. It makes it easier to see what’s happening. Theres an UIView.animate method with a delay parameter, but that wasn’t working for me on the Playground.

Step 7: Flexibility

In order to copy the way the Instagram page control works, we’ve hard coded some values, like the max number of dots, and the number of central dots. That is good for a prototype, but not good for production code, so we’ll change that. We’ll start by adding properties for those two things.

We’ll require that the value for these two properties is odd because our design and our code relies on one dot being centered in the view at all times, which can’t happen if the number of total or central dots is even.

In selectedPage.didSet we’ll change 0...2 to 0..<centerDots. When centerDots was hardcoded to 3, those two were equivalents.

We’ll modify updatePositions() by removing the constants we used before. Instead of adding 2 to -pageOffset, we'll calculate the amount of side pages and use that instead.

The other thing we’ll change is the way we calculate the scale. After calculating the distance, we’ll return a scale of 0 if the distance from the center dot is greater than half the max number of dots. We’ll then subtract the number of dots to the left and right of the central dot from the distance we already had, and then clamp the result to a value between 0 and 3. Using that new value we’ll get a value from an array of values we defined.

Finally, we’ll update intrinsicContentSize to use maxDots instead of the hard coded value we were using.

5 max dots, 1 center dot

One unexpected upside of these last changes is that we can now fully replicate Instagram’s page control functionality. The one difference our controls had was that when Instagram’s page control had five pages or less, it would show all full dots and not scroll, while ours would always show up to three big dots and scroll.

Now we can set maxDots and centerDots to 5 whenever the number of pages is less or equal to 5, and the other values for whenever it’s greater. We won’t implement that by default because I like the scrolling dots.

Instagram 5-page style

Step 8: Final Touches

To wrap it up and make it production ready we’ll add some guards to make sure that the control only updates when the values change. For example, if the number of pages was set to 5, and you set it to 5 again, there’s no reason to update the colors and positions of the views.

We’ll also add some checks to make sure valid values are provided for all variables. For example, you can’t have less than 0 pages.

Another change we’ll make is to change dotSize and spacing to non private variables to add more customization options to our control. We’ll also need to add some property observers to update our control when those values are changed.

Aaaand we’re done!

That’s it, we’ve recreated Instagram’s page control in a class of just 127 lines, and other than the scrolling of the dots, it was pretty straightforward. We started using CoreGraphics to draw our dots, but halfway we realized that UIViews were better suited to do the job. Since our logic was solid and didn’t depend heavily on CoreGraphics, the switch was simple.

If you want your control to be updated as the user is scrolling, like Instagram does, you can override scrollViewDidScroll instead of scrollViewDidEndDecelerating. I didn’t do it in the example because it caused lag in the Playground, I don’t know if it’s an issue with my computer or playgrounds, but it shouldn’t be an issue on the full simulator or on the device.

You can install this in your project with Cocoapods. More information here.