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
withinonSizeChanged
. - 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!
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:
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!