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!