So I Wanted To Animate A UIRefreshControl

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

Background

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

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.

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

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

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

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

  • 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 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

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

The Final Stage

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. このストーリーを好きだったら、拍手して下さいませんか?

Aussie iOS Engineer based in Tokyo, Japan | 日英可