Welcome to the fourth and final part! To wrap up, we're going to implement our refresh animation into a native Android app. If you're looking for part 1, you can find it here.

Full disclaimer – Android is something I'm less familiar with, and therefore I opted to make use of SpringView, an awesome library by liaoinstan. SpringView creates the header above the ScrollView content we'll need to house our Rive animation.

Currently, to set up the Rive runtime for Android, you'll need to generate an android archive from the /kotlin subfolder, as mentioned in the repository readme here. Then, you can follow the steps outlined here to add the library to your project.

To get started with SpringView we need to make a header that we'll pass over to it. Here I created a new BaseSimpleHeader class, set the view height and drag threshold via the appropriate override methods, and set up an AnimationView that'll manage our animation, and we'll be creating next!

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import app.rive.runtime.kotlin.*
import com.liaoinstan.springview.container.BaseSimpleHeader
import com.liaoinstan.springview.widget.SpringView

class AnimatedHeader(context: Context, file: File) : BaseSimpleHeader() {

  private val context: Context
  private var freshTime: Long = 0
  private var file: File? = null
  private var riveView: AnimationView? = null

  init {
    type = SpringView.Type.OVERLAP
    movePara = 2.0f
    this.context = context
    this.file = file
  }

  override fun getView(inflater: LayoutInflater, viewGroup: ViewGroup): View {
    val view = inflater.inflate(R.layout.header, viewGroup, false)
    val headerView  = view.findViewById<LinearLayout>(R.id.root)

    var renderer = Renderer()
    val artboard = file!!.artboard()
    val riveView = AnimationView(renderer, artboard, context)
    this.riveView = riveView

    headerView.addView(riveView)
    return view
  }

  override fun getDragLimitHeight(rootView: View?): Int {
    return 350
  }

  override fun getEndingAnimHeight(rootView: View?): Int {
    return 350
  }

  override fun getDragSpringHeight(rootView: View?): Int {
    return 350
  }

  override fun onPreDrag(rootView: View) {}

  override fun onDropAnim(rootView: View, dy: Int) {
    riveView?.pullExtent = dy
  }

  override fun onLimitDes(rootView: View, upORdown: Boolean) {
    riveView?.isRefreshing = true
  }

  override fun onStartAnim() {
    freshTime = System.currentTimeMillis()
  }

  override fun onFinishAnim() {}

  override fun onResetAnim() {
    riveView?.reset()
  }
}

With the header setup, we can create a new View class that will manage the different states of our refresh animation. Below I've created a new file called AnimationView.kt and outlined the values we want to keep track of...

import android.content.Context
import android.graphics.Canvas
import android.view.View
import app.rive.runtime.kotlin.*
import kotlin.properties.Delegates


class AnimationView : View {
  private var lastTime: Long = 0

  private val renderer: Renderer
  private val artboard: Artboard

  private val idleInstance: LinearAnimationInstance
  private val pullInstance: LinearAnimationInstance
  private val triggerInstance: LinearAnimationInstance
  private val loadingInstance: LinearAnimationInstance

  var isRefreshing: Boolean = false
  var pullExtent: Int by Delegates.observable(0) { _, _, new ->
    targetBounds = AABB(width.toFloat(), new.toFloat())
    isPlaying = new > 1
  }

  lateinit var targetBounds: AABB
  var isPlaying = false
    set(value) {
      if (value != field) {
        field = value
        if (value) {
          lastTime = System.currentTimeMillis()
          invalidate()
        }
      }
    }

  constructor(_renderer: Renderer, _artboard: Artboard, context: Context) : super(context) {
    renderer = _renderer
    artboard = _artboard
    idleInstance = LinearAnimationInstance(_artboard.animation(0))
    pullInstance = LinearAnimationInstance(_artboard.animation(1))
    triggerInstance = LinearAnimationInstance(_artboard.animation(2))
    loadingInstance = LinearAnimationInstance(_artboard.animation(3))
  }
}

Here we're keeping hold of the Rive renderer, artboard, and each of the four animations. We have a pair of booleans for the refresh and playback state, and finally an integer for the pullExtent – the scroll offset as the user pulls downward from the top of the scroll view. The constructor takes an artboard whereby each of the animations are retrieved by their index.

Next, we can outline the functions that make up our AnimationView.

override fun onAttachedToWindow() {
  super.onAttachedToWindow()
  lastTime = System.currentTimeMillis()
}

override fun onDraw(canvas: Canvas) {
  super.onDraw(canvas)
}

fun reset() {
 
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
  super.onSizeChanged(w, h, oldw, oldh);
  targetBounds = AABB(w.toFloat(), h.toFloat());
  invalidate()
}

override fun onDetachedFromWindow() {
  super.onDetachedFromWindow()
  renderer.cleanup()
}

Here we're...

  • Setting the inital value for lastTime within onAttachedWindow.
  • Overriding the onDraw mehtod, whereby we'll manage our animations.
  • Set up a reset method to restore animations to the start after a refresh .has comepleted.
  • Set the targetBounds within onSizeChanged.
  • Clean up when the view is deinitialised.

Let's fill in onDraw and reset...

private fun redraw(canvas: Canvas) {
  val currentTime = System.currentTimeMillis()
  val elapsed = (currentTime - lastTime) / 1000f
  lastTime = currentTime
  renderer.canvas = canvas
  renderer.align(Fit.COVER, Alignment.CENTER, targetBounds, artboard.bounds())

  idleInstance.advance(elapsed)
  idleInstance.apply(artboard, 1f)

  if (!isRefreshing) {
    val pos = pullExtent.toFloat() / 300.toFloat()
    val frames = if (pullInstance.animation.workEnd > 0) {
      pullInstance.animation.workEnd - pullInstance.animation.workStart
    } else {
      pullInstance.animation.duration
    }
    val time = frames.toFloat() / pullInstance.animation.fps.toFloat()
    pullInstance.time(time * pos)
    pullInstance.apply(artboard, 1f)
  } else {
    triggerInstance.apply(artboard, 1f)
    triggerInstance.advance(elapsed)
    val triggerTime = triggerInstance.time()
    val triggerAnimation = triggerInstance.animation
    if (triggerTime >= triggerAnimation.workEnd / triggerAnimation.fps) {
      loadingInstance.apply(artboard, 1f)
      loadingInstance.advance(elapsed)
    }
  }

  canvas.save()
  artboard.advance(elapsed)
  artboard.draw(renderer)
  canvas.restore()

  if (isPlaying) {
    invalidate()
  }
}

Our onDraw method will get called each frame, so we start out by calculating the amount of time that has passed since the last loop. Assuming we're running at 60 fps, that will likely be around 0.016 seconds. We'll use this value to progress our animations.

Next up, we set our desired fit and alignment for the animation, then apply our idle animation...

Our onDraw method will get called each frame, so we start out by calculating the amount of time that has passed since the last loop. Assuming we're running at 60 fps, that will likely be around 0.016 seconds. We'll use this value to progress our animations.

Next up, we set our desired fit and alignment for the animation, then apply our idle animation...

idleInstance.advance(elapsed)
idleInstance.apply(artboard, 1f)

Here we advance the animation by the amount of time that passed since the last draw, then apply it to our artboard ready to be drawn once again.

Things are a little different for the pull animation, as rather than advancing it in a linear fashion, we want to map it to the offset and the user drags downward...

if (!isRefreshing) {
  val pos = pullExtent.toFloat() / 300.toFloat()
  val frames = if (pullInstance.animation.workEnd > 0) {
    pullInstance.animation.workEnd - pullInstance.animation.workStart
  } else {
    pullInstance.animation.duration
  }
  val time = frames.toFloat() / pullInstance.animation.fps.toFloat()
  pullInstance.time(time * pos)
  pullInstance.apply(artboard, 1f)
}

Here, if the refresh hasn't yet been triggered, we divide the pullExtent provided by the AnimationHeader by the distance we want the animation to play through to. 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.

Then we get the end value in seconds by dividing the final frame number (determined by whether a work area is enabled or not) by the fps. As we know we have a 1 second pull animation, we could make this simpler...

if (!isRefreshing) {
  val pos = pullExtent.toFloat() / 300.toFloat()
  pullInstance.time(pos)
  pullInstance.apply(artboard, 1f)
}

However by checking for the final frame in a more robust manner, we're able to more freely switch out our Rive file for other refresh animations that may have a longer pull animation duration, for instance.

Lastly, we need to apply the trigger and loading animations. Once a refresh has been triggered, we start advancing the trigger animation. Once that's completed, we start playing the loading animation, which was set to loop within the editor.

if (!isRefreshing) {

  ...
  
} else {
  triggerInstance.apply(artboard, 1f)
  triggerInstance.advance(elapsed)
  val triggerTime = triggerInstance.time()
  val triggerAnimation = triggerInstance.animation
  if (triggerTime >= triggerAnimation.workEnd / triggerAnimation.fps) {
    loadingInstance.apply(artboard, 1f)
    loadingInstance.advance(elapsed)
  }
}

The final part of our onDraw method applies our animations to the canvas, and uses invalidate() to prompt a new frame, assuming isPlaying is still marked as true.

private fun redraw(canvas: Canvas) {

  ...

  canvas.save()
  artboard.advance(elapsed)
  artboard.draw(renderer)
  canvas.restore()

  if (isPlaying) {
    invalidate()
  }
}

The final thing to do is set our trigger and loading animations back to the beginning once the refresh interaction has completed, ready for the next one...

fun reset() {
  isRefreshing = false
  triggerInstance.time(0f)
  triggerInstance.apply(artboard, 1f)
  loadingInstance.time(0f)
  loadingInstance.apply(artboard, 1f)
}

With that complete, our AnimationView.kt looks like this...

import android.content.Context
import android.graphics.Canvas
import android.view.View
import app.rive.runtime.kotlin.*
import kotlin.properties.Delegates


class AnimationView : View {
  private var lastTime: Long = 0

  private val renderer: Renderer
  private val artboard: Artboard

  private val idleInstance: LinearAnimationInstance
  private val pullInstance: LinearAnimationInstance
  private val triggerInstance: LinearAnimationInstance
  private val loadingInstance: LinearAnimationInstance

  var isRefreshing: Boolean = false
  var pullExtent: Int by Delegates.observable(0) { _, _, new ->
    targetBounds = AABB(width.toFloat(), new.toFloat())
    isPlaying = new > 1
  }

  lateinit var targetBounds: AABB
  var isPlaying = false
  set(value) {
    if (value != field) {
      field = value
      if (value) {
        lastTime = System.currentTimeMillis()
        invalidate()
      }
    }
  }

  constructor(_renderer: Renderer, _artboard: Artboard, context: Context) : super(context) {
    renderer = _renderer
    artboard = _artboard
    idleInstance = LinearAnimationInstance(_artboard.animation(0))
    pullInstance = LinearAnimationInstance(_artboard.animation(1))
    triggerInstance = LinearAnimationInstance(_artboard.animation(2))
    loadingInstance = LinearAnimationInstance(_artboard.animation(3))
  }

  override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    lastTime = System.currentTimeMillis()
  }

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    val currentTime = System.currentTimeMillis()
    val elapsed = (currentTime - lastTime) / 1000f
    lastTime = currentTime
    renderer.canvas = canvas
    renderer.align(Fit.COVER, Alignment.CENTER, targetBounds, artboard.bounds())

    idleInstance.advance(elapsed)
    idleInstance.apply(artboard, 1f)

    if (!isRefreshing) {
      val pos = pullExtent.toFloat() / 300.toFloat()
      val frames = if (pullInstance.animation.workEnd > 0) {
        pullInstance.animation.workEnd - pullInstance.animation.workStart
      } else {
        pullInstance.animation.duration
      }
      val time = frames.toFloat() / pullInstance.animation.fps.toFloat()
      pullInstance.time(time * pos)
      pullInstance.apply(artboard, 1f)
    } else {
      triggerInstance.apply(artboard, 1f)
      triggerInstance.advance(elapsed)
      val triggerTime = triggerInstance.time()
      val triggerAnimation = triggerInstance.animation
      if (triggerTime >= triggerAnimation.workEnd / triggerAnimation.fps) {
        loadingInstance.apply(artboard, 1f)
        loadingInstance.advance(elapsed)
      }
    }

    canvas.save()
    artboard.advance(elapsed)
    artboard.draw(renderer)
    canvas.restore()

    if (isPlaying) {
      invalidate()
    }
  }

  fun reset() {
    isRefreshing = false
    triggerInstance.time(0f)
    triggerInstance.apply(artboard, 1f)
    loadingInstance.time(0f)
    loadingInstance.apply(artboard, 1f)
  }

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh);
    targetBounds = AABB(w.toFloat(), h.toFloat());
    invalidate()
  }

  override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    renderer.cleanup()
  }
}

All that's left to do is load our Rive file and initialise the AnimationHeader we created earlier within the onCreate function of our MainActivity.kt file.

class MainActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    var file = File(resources.openRawResource(R.raw.space_reload).readBytes())
    val springView = findViewById<SpringView>(R.id.springview)
    var header = AnimatedHeader(this, file)
    springView?.header = header
    springView?.setListener(object : OnFreshListener {
      override fun onRefresh() {
        Handler()
          .postDelayed({ springView?.onFinishFreshAndLoad() }, 3000)
      }
      override fun onLoadmore() {
        Handler()
          .postDelayed({ springView?.onFinishFreshAndLoad() }, 3000)
      }
    })
  }
}

That's it! you should now be able to run the app and test out your pull-to-refresh animation!

You can also take a look at how the activity_main.xml file is structured here.

Next steps

If you haven't already, be sure to check out parts 2 and 3 if you're interested in implementing our pull-to-refresh animation in Flutter and native iOS!

Part 2 - Flutter
Part 3 - iOS

Beyond creating a custom pull-to-refresh animation, I hope this post has helped shed some light on how the Rive runtimes work, and how to get started with them across iOS, Android, and Flutter! Here are the GitHub links again:

Flutter - iOS - Android

With the runtimes setup, you can now switch out Rive files for entirely different animations, providing they follow the same structure of using an idle, pull, trigger, and loading animation. Check out some more examples below, no code adjustments required!