So I Wanted To Animate A UIRefreshControl

She’s Kinda Ghetto But Still Like, You Know….Eloquent

Benjamin
6 min readFeb 10, 2019

Background

The default UIRefreshControl is completely adequate. Why?

It does what it says on the tin — it enables the user to control when the app they’re using reloads, and to see when that’s happening.

I’m writing this because I wanted to add branding to my app. You sometimes need to focus on every detail, big and small, to deliver a truly great experience to your user.

When you have branding in an app, the use of bog-standard components can easily jar the user out of whatever experience, you’re trying to create for them.

The UIRefreshControl was no exception to this rule. It didn’t fit in, it stuck out. Attempting to skin it was an exercise in futility. The code I wrote, was highly subject to breakage in future iOS versions.

Inevitably, I decided to roll my own.

The Effect

First, I defined the behaviours of UIRefreshControl (in graph form, using Illustrator), by observing it in action.

There's one trigger. Which is the user dragging. When they reach a predefined threshold, the animation is initiated, and associated action executed.

The Goopery. The Gag Of It All.

Right away, I knew I didn’t want to make my custom control display a spinner, and close. How boring. I wanted to have some fun.

Having fun, means using something called UIBezierPath. In the above GIF, you can see it at work.

The UIBezierPath draws a “divot” in the UI as you pull down, giving the illusion that the UITableView beneath it is actually distorting — it’s not, by the way.

The divot also (unnecessarily) moves to the current x position of your touch. If you’ve ever read a post of mine, you’ll know my UI experiments are always as extra as possible. Feel free to omit this in your implementation.

To present the path on the screen I decided to use CAShapeLayer. Each control point of the bezier path is represented by aUIView, so I can animate everything into position.

Unfortunately, I cannot animate values of a CGPoint directly. So the necessary views are invisible with no size. But they serve the effect.

When my finger moves, all control point views are moved, and the path is set to a new value. If I am past a certain threshold, I animate to a predefined offset, and start the animation. Otherwise, I close the view.

Let’s Make It

Go ahead and make a new “Single View” project in Xcode.

Open up the Storyboard, add a UITableView, wire up the delegate and the dataSource, add a dynamic cell prototype with identifier “Cell". Make sure this has a style of “Basic” and then add an IBOutlet for the table view in the corresponding ViewController class file.

Lastly, add a didSet block onto your IBOutlet — we’ll use this later.

Declare a class variable in your View Controller called dataSource (for lack of a better name):

let dataSource = (1...100).map(String.init)

Next, ensure that numberOfRowsInSection is the count of your dataSource. Then, you can dequeueReusableCell inside cellForRowAt:indexPath and set the cell’s textLabel.text to one of your dataSource objects.

Create a new blank UIView subclass, and call it RefreshControl.

Remember that I wanted to use the same interface for adding a UIRefreshControl to our table/collection view. I need to use KVO in order to get that done.

So, replace your class with the following:

Breakpoint

The usual way of adding a UIRefreshControl to a table or collection view, is to set its backgroundView property.

Example:

tableView.backgroundView = UIRefreshControl()

I’m doing that here too, but I need to listen to some parent-view properties in order to make it behave in the same way.

This “listening” is called Key-Value Observation. I want updates when contentOffset, contentInset, frame orpanGestureRecognizer.state changes, so I “observe” the “key” and take the "value."

There are of course other ways to achieve this. But I’m sticking with KVO for demo purposes.

Drawing The Path

You might have noticed that there are some things in the previous gist that don’t quite work. This is the state and path .

So let’s draw the path! Go ahead and replace your file with this gist.

Jump into the ViewController, and bang the following in there:

private lazy var refreshControl: RefreshControl = {
let refreshControl = RefreshControl()
return refreshControl
}()
@IBOutlet private weak var tableView: UITableView! {
didSet {
tableView.backgroundView = refreshControl
}
}

If you build and run the app, and “pull to refresh,” you’ll see the custom view and path!

If you merely want a slick UI effect, and some branding to appear when you overscroll, you can stop now. If you want to reload, continue on.

Now I have to make this component do it’s real job.

Breakpoint

The bezier point creation is as follows:

  • We “move” to (0,0) (the top left corner),
  • Draw a line to L
  • Draw a line from L to C
  • Draw a line from C to R
  • Draw a line from R to (1,0) (the top right corner)

When you close() the path, the black area pictured above is the result!

In the “stopped” state, the path is rectangle.

But math dictates that as the centre point moves downward, the left and right control points move slower than it, which creates the “divot” effect.

Maintaining State

In order for this component to act like a “real” UIRefreshControl, I need to maintain some state.

  • When I pass a given threshold, initiate the refresh animation.
  • If I don’t pass the threshold, collapse as normal.

When I let go, past a certain point, I snap to a preset height. If I let go before that, I want to shrink back to nothing. Then we can go again! And again!

Breakpoint

I need to utilise a CADisplayLink in my implementation.

I need this, so I can hijack the default animation that fires when I let go during overscroll. A CADisplayLink is basically a fancy Timer which fires at the rate the display refreshes.

When the control is “loading,” I want to keep the scroll view’s offset at a given point until I give the signal to collapse.

The Loading View

Time for another new file! Make this one a subclass of UIView and paste in the following gist:

This will, once used, take a progress float value, and animate a spinner.

The Final Stage

For the final stage, I’ll utilise the loading view declared earlier. I’ll also tidy up my files a bit, strictly for readability purposes — NOTE: I do not condone typealiasing extensions in order to namespace them.

I’ve also tidied up my magic numbers. I’ve defined them inside a struct Constants {}. This is a good pattern to get into when you need numbers in your code. Define, and potentially change them, in one place.

Here’s the final gist!

Paste the above gist in, and update your ViewController to set the actionHandler for demonstration purposes. We’ll animate for 1 second, and then terminate.

Remember to deinit your observers!

private lazy var refreshControl: WarpingRefreshControl = {
let refreshControl = WarpingRefreshControl()
refreshControl.actionHandler = { [weak self] in
self?.stopLoading()
}
return refreshControl
}()
@IBOutlet private weak var tableView: UITableView! {
didSet {
tableView.backgroundView = refreshControl
}
}
private func stopLoading() {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
self?.refreshControl.stopLoading()
}
}

Now when you run the app, you’ll see the effect and the animation. Where to go from here, is entirely up to you.

Thanks for reading!

If you liked this post, consider giving me a clap, or seven. このストーリーを好きだったら、拍手して下さいませんか?

--

--

Benjamin
Benjamin

Written by Benjamin

Senior iOS Engineer in Tokyo | 日英可

Responses (1)