Welcome to part 3! In this part we're going to implement our Rive animation into a native iOS app. If you're looking for part 1, you can find it here.
We're going to be using regular UIKit in this example, but stay tuned for future examples using SwiftUI!
I should also point out that iOS does have a UIRefreshControl
class which handles all the necessary interactions and can easily be assigned to a UITableViewController
or UITableView
. However, we found it restrictive in adjusting the height of the view itself, so opted to manually manage the interaction side of things. That said, if you're not fussy about having a set view height, then embedding your Rive animation into a UIRefreshControl
makes things much simpler! You can find both examples on GitHub – the custom implementation in the main
branch, and the UIRefreshControl
variant in the refresh_control
branch.
To get started, create a new Storyboard-based iOS project.
Add the Rive runtime to your project via drag and drop or the Add Files option under the File menu. Lastly, under the project's General settings, select + under 'Frameworks, Libraries, and Embedded Content' and choose RiveRuntime.framework
. If you get an error, try running the project. The runtime will be available via Cocoapods soon!
Let's start by creating a new file to subclass UITableViewController
. I've included the override functions we'll be using...
import UIKit
class TableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
}
override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
}
}
Head over to Main.storyboard
to assign your TableViewController
class. Here we're using a UINavigationController
linked to a UITableViewController
in order to get a navigation bar within a vertically scrolling list beneath.

We'll come back to filling in our TableViewController
later on. First, let's create a new file that'll manage our Rive animation. Subclass UIView
, import the Rive runtime, and add in the following variables, initialisers, and functions...
import UIKit
import RiveRuntime
class RefreshView: UIView {
private var artboard: RiveArtboard?
private var displayLink: CADisplayLink?
private var lastTime: CFTimeInterval = 0
private var idle: RiveLinearAnimationInstance?
private var pull: RiveLinearAnimationInstance?
private var trigger: RiveLinearAnimationInstance?
private var loading: RiveLinearAnimationInstance?
let frameHeight: CGFloat
var isRefreshing: Bool = false
var _pulledExtent: CGFloat = 0.0
var pulledExtent: CGFloat {
set { _pulledExtent = -newValue }
get { return _pulledExtent }
}
var isPaused: Bool = false {
didSet {
displayLink?.isPaused = isPaused
}
}
required init(frameHeight: CGFloat) {
self.frameHeight = frameHeight
super.init(frame: .zero)
initRive()
}
private override init(frame: CGRect) {
self.frameHeight = 180
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
}
func initRive() {
}
func initTimer() {
displayLink = CADisplayLink(target: self, selector: #selector(apply));
displayLink?.add(to: .main, forMode: .common)
}
func removeTimer() {
displayLink?.remove(from: .main, forMode: .common)
}
@objc func apply() {
}
func reset() {
}
}
We're going to predominately focus on initRive
, apply
, and reset
. Outside of that, we're holding onto our four animations via the RiveLinearAnimationInstance
variables, using a CADisplayLink
to control our run loop, and keeping track of values such as the pulledExtent
that'll be updated by the scrollViewDidScroll
method in our TableViewController
's delegate.
Let's start with initRive
...
func initRive() {
guard let url = Bundle.main.url(forResource: "space_reload", withExtension: "riv") else {
fatalError("Failed to locate resource in bundle.")
}
guard var data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(url) from bundle.")
}
// Import the data into a RiveFile
let bytes = [UInt8](data)
data.withUnsafeMutableBytes{(riveBytes:UnsafeMutableRawBufferPointer) in
guard let rawPointer = riveBytes.baseAddress else {
fatalError("File pointer not found")
}
let pointer = rawPointer.bindMemory(to: UInt8.self, capacity: bytes.count)
guard let riveFile = RiveFile(bytes:pointer, byteLength: UInt64(bytes.count)) else {
fatalError("Failed to import \(url).")
}
let artboard = riveFile.artboard()
if (artboard.animationCount() == 0) {
fatalError("No animations in the file.")
}
let idleAnimation = artboard.animation(at: 0)
self.idle = idleAnimation.instance()
let pullAnimation = artboard.animation(at: 1)
self.pull = pullAnimation.instance()
self.pull?.setTime(1)
let triggerAnimation = artboard.animation(at: 2)
self.trigger = triggerAnimation.instance()
let loadingAnimation = artboard.animation(at: 3)
self.loading = loadingAnimation.instance()
self.artboard = artboard
initTimer()
}
}
We locate the Rive file and parse the data to bytes. After a series of checks that everything has successfully been found and parsed, we grab the artboard from the file and fetch the animations by index. After assigning the animations to the defined class variables, we hold onto the artboard and initialise the timer for the run loop, a perfect segue to setting up our apply
method...
@objc func apply() {
guard let displayLink = displayLink,
let artboard = artboard else {
removeTimer()
return
}
let timestamp = displayLink.timestamp
if (lastTime == 0) {
lastTime = timestamp
}
// Calculate the time elapsed between ticks
let elapsedTime = timestamp - lastTime
lastTime = timestamp;
let idleTime = idle?.time() ?? 0
idle?.animation().apply(idleTime, to: artboard)
idle?.advance(by: elapsedTime)
if trigger?.time() == 0 {
let pos = Float(_pulledExtent / frameHeight)
let pullTime = pull?.time() ?? 1
pull.animation().apply(pullTime * pos, to: artboard)
}
if isRefreshing {
let triggerTime = trigger?.time() ?? 0
trigger?.animation().apply(triggerTime, to: artboard)
trigger?.advance(by: elapsedTime)
if trigger?.hasCompleted ?? false {
let loadingTime = loading?.time() ?? 0
loading?.animation().apply(loadingTime, to: artboard)
loading?.advance(by: elapsedTime)
}
}
artboard.advance(by: elapsedTime)
setNeedsDisplay()
}
We start by checking we have a display link and artboard stored from the initRive
method. Then, we use the display link's timestamp to calculate the amount of time that has passed since the last frame (assuming we're running at 60 fps, that should fall somewhere around 0.016 seconds).
Next up, we start applying and advancing the animations themselves. That starts by getting the current time for an animation, then applying it to our artboard, before we advance the time ready to be applied when the next frame is called. This is best demonstrated for the idle animation.
let idleTime = idle?.time() ?? 0 // get the current time for the animation
idle?.animation().apply(idleTime, to: artboard) // apply it to the artboard
idle?.advance(by: elapsedTime) // progress the time for the next frame
With the idle animation applied, we now want to add the pull animation into the mix. In this instance, instead of advancing time to play the animation in a linear fashion, we divide the _pulledExtent
that will be supplied by the scrollView
by the height of our refresh header. This will give us a value of 0
when there is no downward drag, and 1
once the drag position reaches the threshold, along with everything in between. Since we used self.pull?.setTime(1)
to set the animation to it's end point (it's a 1 second animation), when we divide it by our pos
calculation it'll map the drag position to the duration of the animation!
Finally, if our isRefreshing
boolean is true, we start animating the trigger animation, and once that's played through we start looping the loading animation until the refresh action has completed. Note I added a small extension to RiveLinearAnimationInstance
to check if the trigger animation has completed.
extension RiveLinearAnimationInstance {
var hasCompleted: Bool {
let anim = self.animation()
let time = self.time()
return time >= Float(anim.workEnd() / anim.fps())
}
}
At the end of our apply
method we use setNeedsDisplay()
to prompt our UIView
to redraw. We can override the draw
method to add our Rive renderer and draw our artboard to it.
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let artboard = self.artboard else {
return
}
let renderer = RiveRenderer(context: context);
renderer.align(with: rect,
withContentRect: artboard.bounds(),
with: .Center,
with: .Cover)
artboard.draw(renderer)
}
All that's left is to create a method to handle resetting our trigger and loading animations once the reset interaction has completed...
func reset() {
loading?.setTime(0)
trigger?.setTime(0)
if let artboard = artboard {
loading?.animation().apply(0, to: artboard)
trigger?.animation().apply(0, to: artboard)
}
}
With that, your RefreshView.swift
should look something like this...
import UIKit
import RiveRuntime
class RefreshView: UIView {
private var artboard: RiveArtboard?
private var displayLink: CADisplayLink?
private var lastTime: CFTimeInterval = 0
private var idle: RiveLinearAnimationInstance?
private var pull: RiveLinearAnimationInstance?
private var trigger: RiveLinearAnimationInstance?
private var loading: RiveLinearAnimationInstance?
let frameHeight: CGFloat
var isRefreshing: Bool = false
var _pulledExtent: CGFloat = 0.0
var pulledExtent: CGFloat {
set { _pulledExtent = -newValue }
get { return _pulledExtent }
}
var isPaused: Bool = false {
didSet {
displayLink?.isPaused = isPaused
}
}
required init(frameHeight: CGFloat) {
self.frameHeight = frameHeight
super.init(frame: .zero)
initRive()
}
private override init(frame: CGRect) {
self.frameHeight = 180
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let artboard = self.artboard else {
return
}
let renderer = RiveRenderer(context: context);
renderer.align(with: rect,
withContentRect: artboard.bounds(),
with: .Center,
with: .Cover)
artboard.draw(renderer)
}
func initRive() {
guard let url = Bundle.main.url(forResource: "space_reload", withExtension: "riv") else {
fatalError("Failed to locate resource in bundle.")
}
guard var data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(url) from bundle.")
}
let bytes = [UInt8](data)
data.withUnsafeMutableBytes{(riveBytes:UnsafeMutableRawBufferPointer) in
guard let rawPointer = riveBytes.baseAddress else {
fatalError("File pointer is messed up")
}
let pointer = rawPointer.bindMemory(to: UInt8.self, capacity: bytes.count)
guard let riveFile = RiveFile(bytes:pointer, byteLength: UInt64(bytes.count)) else {
fatalError("Failed to import \(url).")
}
let artboard = riveFile.artboard()
if (artboard.animationCount() == 0) {
fatalError("No animations in the file.")
}
let idleAnimation = artboard.animation(at: 0)
self.idle = idleAnimation.instance()
let pullAnimation = artboard.animation(at: 1)
self.pull = pullAnimation.instance()
self.pull?.setTime(1)
let triggerAnimation = artboard.animation(at: 2)
self.trigger = triggerAnimation.instance()
let loadingAnimation = artboard.animation(at: 3)
self.loading = loadingAnimation.instance()
self.artboard = artboard
initTimer()
}
}
func initTimer() {
displayLink = CADisplayLink(target: self, selector: #selector(apply));
displayLink?.add(to: .main, forMode: .common)
}
func removeTimer() {
displayLink?.remove(from: .main, forMode: .common)
}
@objc func apply() {
guard let displayLink = displayLink,
let artboard = artboard else {
removeTimer()
return
}
let timestamp = displayLink.timestamp
if (lastTime == 0) {
lastTime = timestamp
}
// Calculate the time elapsed between ticks
let elapsedTime = timestamp - lastTime
lastTime = timestamp;
let idleTime = idle?.time() ?? 0
idle?.animation().apply(idleTime, to: artboard)
idle?.advance(by: elapsedTime)
if trigger?.time() == 0 {
let pos = Float(_pulledExtent / frameHeight)
let pullTime = pull?.time() ?? 1
pull?.animation().apply(pullTime * pos, to: artboard)
}
if isRefreshing {
let triggerTime = trigger?.time() ?? 0
trigger?.animation().apply(triggerTime, to: artboard)
trigger?.advance(by: elapsedTime)
if trigger?.hasCompleted ?? false {
let loadingTime = loading?.time() ?? 0
loading?.animation().apply(loadingTime, to: artboard)
loading?.advance(by: elapsedTime)
}
}
artboard.advance(by: elapsedTime)
setNeedsDisplay()
}
func reset() {
loading?.setTime(0)
trigger?.setTime(0)
if let artboard = artboard {
loading?.animation().apply(0, to: artboard)
trigger?.animation().apply(0, to: artboard)
}
}
}
extension RiveLinearAnimationInstance {
var hasCompleted: Bool {
let anim = self.animation()
let time = self.time()
return time >= Float(anim.workEnd() / anim.fps())
}
}
Now let's head back to our TableViewController
to connect our Rive animation view with the TableView
header.
import UIKit
class TableViewController: UITableViewController {
var refreshView: RefreshView?
var pendingReset: Bool = false
let refreshTriggerPullDistance: CGFloat = 180
let refreshViewExtent: CGFloat = 180
override func viewDidLoad() {
super.viewDidLoad()
refreshView = RefreshView(frameHeight: refreshViewExtent)
let containerView = UIView()
containerView.addSubview(refreshView!)
tableView.backgroundView = containerView
refreshView?.isPaused = true
}
// TableView DataSource and Delegate methods...
}
We can set up our RefreshView
and add it to the backgroundView
of out tableView
then get into the delegate methods...
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
refreshView?.isPaused = offset >= 0
refreshView?.pulledExtent = offset
refreshView?.frame = CGRect(
x: 0, y: 0,
width: view.bounds.width,
height: -offset)
}
In scrollViewDidScroll
we can pause the refreshView
based on the offset so that our animation loop only runs if the header is revealed. Then, we pass our refreshView
the offset so it can interpolate our pull animation. Finally, we set the frame of refreshView
in order to fit within the space above the tableView
's content.
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y < -refreshTriggerPullDistance {
beginRefreshing()
}
}
We can use the scrollViewDidEndDragging
method to trigger the refresh interaction if the user drags to our defined threshold.
override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
if pendingReset {
tableView.contentInset = .zero
refreshView?.isRefreshing = false
refreshView?.reset()
pendingReset = false
}
}
And finally, we can use scrollViewDidEndScrollingAnimation
to detect when the header has finished animating back into place after the refresh has completed. At this point we can reset out refreshView
ready for the next interaction.
The last thing to add is a pair of functions to expand the top inset of out tableView
on triggering a refresh, and collapsing it again once it's completed. This will create a space above the content for our animation to play within.
func beginRefreshing() {
UIView.animate(
withDuration: 0.15,
delay: 0.0,
options: [.allowUserInteraction, .beginFromCurrentState],
animations: {
self.tableView.contentInset =
.init(top: self.refreshViewExtent, left: 0, bottom: 0, right: 0)
self.tableView.setContentOffset(
.init(x: 0, y: -self.refreshViewExtent), animated: true)
self.refreshView?.isRefreshing = true
}, completion: { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.endRefreshing()
}
})
}
func endRefreshing() {
UIView.animate(
withDuration: 2.3,
delay: 0.0,
options: [.beginFromCurrentState]) {
self.tableView.setContentOffset(.zero, animated: true)
self.pendingReset = true
}
}
That's it! Your TableViewController
should now look something like this...
import UIKit
class TableViewController: UITableViewController {
var refreshView: RefreshView?
var pendingReset: Bool = false
let refreshTriggerPullDistance: CGFloat = 180
let refreshViewExtent: CGFloat = 180
override func viewDidLoad() {
super.viewDidLoad()
refreshView = RefreshView(frameHeight: refreshViewExtent)
let containerView = UIView()
containerView.addSubview(refreshView!)
tableView.backgroundView = containerView
refreshView?.isPaused = true
}
func beginRefreshing() {
UIView.animate(
withDuration: 0.15,
delay: 0.0,
options: [.allowUserInteraction, .beginFromCurrentState],
animations: {
self.tableView.contentInset =
.init(top: self.refreshViewExtent, left: 0, bottom: 0, right: 0)
self.tableView.setContentOffset(
.init(x: 0, y: -self.refreshViewExtent), animated: true)
self.refreshView?.isRefreshing = true
}, completion: { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
self.endRefreshing()
}
})
}
func endRefreshing() {
UIView.animate(
withDuration: 2.3,
delay: 0.0,
options: [.beginFromCurrentState]) {
self.tableView.setContentOffset(.zero, animated: true)
self.pendingReset = true
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
refreshView?.isPaused = offset >= 0
refreshView?.pulledExtent = offset
refreshView?.frame = CGRect(
x: 0, y: 0,
width: view.bounds.width,
height: -offset)
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y < -refreshTriggerPullDistance {
beginRefreshing()
}
}
override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
if pendingReset {
tableView.contentInset = .zero
refreshView?.isRefreshing = false
refreshView?.reset()
pendingReset = false
}
}
}
As I mentioned at the start, an alternative approach would be to subclass UIRefreshControl
instead of UIView
. Whilst managing the animations is the same, it simplifies the UITableViewController
at the expense of a restricted header height. If you're interested in that example, you can find it here.
Next steps
You can find the code for this project here.
Be sure to check out parts 2 and 4 if you're interested in implementing the pull-to-refresh animations in Flutter and native Android!
Part 2 - Flutter
Part 4 - Android
You can also switch out the Rive file for entirely new animations, providing they follow the same structure of using an idle, pull, trigger, and loading animation. You can see other examples below, no code adjustments required!