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