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 popular apps and explain step by step how it’s done.
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
CGSize, etc., as well as
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
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
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.
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 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
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
1, we’ll return a scale of
1, as the distance grows bigger, we’ll return smaller numbers until we reach 0.
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.
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).
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
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.
selectedPage.didSet we’ll change
centerDots was hardcoded to
3, those two were equivalents.
updatePositions() by removing the constants we used before. Instead of adding
-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
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.
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
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.
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
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.