Welcome back! In this part we're going to implement our Rive animation into a Flutter app. If you're looking for part 1, you can find it here.

I'm going to assume you have Flutter installed and have created a new project, however if you need help on those steps then be sure to check out Flutter's guide on getting started here.

To add Rive to your project, add rive: ^0.6.3 to your pubspec.yaml.

Start by creating a new file, refresh_controller.dart, and extending RiveAnimationController. It provides us with a number of override methods – we'll be focusing on init and apply.

It's worth mentioning that we'll be making use of the CupertinoSliverRefreshControl widget, hence importing the Cupertino package and some associated values.

import 'package:flutter/cupertino.dart';
import 'package:rive/rive.dart';

class RefreshController extends RiveAnimationController<RuntimeArtboard> {

  RuntimeArtboard _artboard;

  /// Our four different animations
  LinearAnimationInstance _idle;
  LinearAnimationInstance _pull;
  LinearAnimationInstance _trigger;
  LinearAnimationInstance _loading;

  /// Values from CupertinoSliverRefreshControl builder function
  RefreshIndicatorMode refreshState;
  double pulledExtent;
  double triggerThreshold;
  double refreshIndicatorExtent;
  
  double get _pullPos {
    return pulledExtent / triggerThreshold;
  }

  @override
  bool init(RuntimeArtboard artboard) {}

  @override
  void apply(RuntimeArtboard artboard, double elapsedSeconds) {}

  @override 
  void dispose() {}

  @override
  void onActivate() {}

  @override
  void onDeactivate() {}
}

Now let's grab and hold onto each of our four animations by name within the init method...

@override
bool init(RuntimeArtboard artboard) {

  _artboard = artboard;
  _idle = artboard.animationByName('Idle');
  _pull = artboard.animationByName('Pull');
  _trigger = artboard.animationByName('Trigger');
  _loading = artboard.animationByName('Loading');

  _pull.time = _pull.animation.enableWorkArea
    ? _pull.animation.workEnd / _pull.animation.fps
    : _pull.animation.duration / _pull.animation.fps;

  isActive = true;
  return _idle != null;
}

Here we're:

  • Storing the artboard
  • Grabbing the animations and assigning them to the variables we defined at the start
  • Setting the pull animation's time to the end (more on that later)
  • Setting the RiveAnimationController's isActive variable to true to enable the apply loop

Next up, let's structure the loop by filling out the apply method. For each animation we want to apply the key-framed values for a moment in time to the artboard, then advance the animation time ready for the next frame. Let's do that for the idle animation...

@override
void apply(RuntimeArtboard artboard, double elapsedSeconds) {

  // Idle animation
  _idle.animation.apply(_idle.time, coreContext: artboard);
  _idle.advance(elapsedSeconds);
}

Suppose idle.time was 0 (i.e. the start of the animation), we'd be applying the initial values for all key-framed properties to our artboard. Then, we use the advance method to progress _idle.time. Now when the next frame gets called, _idle.time will be around 0.016 (assuming we're running at 60 frames per second), and so we'll be applying new values for key-framed properties to the artboard before advancing another 0.016 seconds.

Now let's add the pull animation...

@override
void apply(RuntimeArtboard artboard, double elapsedSeconds) {

  // Idle animation
  _idle.animation.apply(_idle.time, coreContext: artboard);
  _idle.advance(elapsedSeconds);
  
  // Pull animation
  if (_trigger.time == 0) {
    _pull.animation.apply(_pull.time * _pullPos, coreContext: artboard);
  }
}

In this case we're only applying the pull animation if the trigger has yet to begin. This ensures it doesn't get played in reverse as the view collapses once the refresh is complete. We will reset the _trigger.time back to 0 after that's happened.

You might notice we're not advancing the _pull.time here. Instead, we set the time to the end of the animation inside the init method at the start...

_pull.time = _pull.animation.enableWorkArea
  ? _pull.animation.workEnd / _pull.animation.fps
  : _pull.animation.duration / _pull.animation.fps;

Here we check if a work area is enabled, and find the end time accordingly. Instead of advancing, we set the time to the end and multiply it by the _pullPos value we defined at the start. Let's take another look at that...

double get _pullPos 
  return pulledExtent / triggerThreshold;
}

It divides the pulledExtent (the distance the user has dragged downward) by the threshold we've set to trigger the refresh. Both of these values will be supplied by the CupertinoSliverRefreshControl widget later on. 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.

Multiplying this value by the end time of _pull will map the animation to the drag interaction!

With that done, lets finish by adding the trigger and loading animations into the mix...

@override
void apply(RuntimeArtboard artboard, double elapsedSeconds) {

  // Idle animation
  _idle.animation.apply(_idle.time, coreContext: artboard);
  _idle.advance(elapsedSeconds);

  // Pull animation
  if (_trigger.time == 0) {
    _pull.animation.apply(_pull.time * _pullPos, coreContext: artboard);
  }

  // Trigger animation
  if (refreshState == RefreshIndicatorMode.refresh ||
      refreshState == RefreshIndicatorMode.armed) {  
      
    _trigger.animation.apply(_trigger.time, coreContext: artboard);
    _trigger.advance(elapsedSeconds);
    
    // Loading animation
    if (_trigger.time >= _trigger.animation.workEnd / _trigger.animation.fps) {
      _loading.animation.apply(_loading.time, coreContext: artboard);
      _loading.advance(elapsedSeconds);
    }
  }
}

Here we check the refreshState provided by the CupertinoSliverRefreshControl widget, and play the trigger animation accordingly.

Finally, we check if the trigger animation has finished, and once it has we begin playing the loading animation. Remember, we defined trigger as a one-shot animation, whereas loading is a looping animation. This ensures the trigger plays once, and promptly moves onto the loading animation that will play continuously until the refresh has completed.

Let's finish up by adding a method to reset everything once a refresh has completed.

void reset() {
  if (pulledExtent != null && triggerThreshold != null) {
    if (pulledExtent < triggerThreshold) {

      final triggerStartFrame = _trigger.animation.enableWorkArea ? _trigger.animation.workStart : 0;
      _trigger.time = triggerStartFrame.toDouble() / _trigger.animation.fps;

      final loadingStartFrame = _loading.animation.enableWorkArea ? _loading.animation.workStart : 0;
      _loading.time = loadingStartFrame.toDouble() / _loading.animation.fps;
        
      _loading.animation.apply(_loading.time, coreContext: _artboard);
      _trigger.animation.apply(_trigger.time, coreContext: _artboard);
      _pull.animation.apply(0, coreContext: _artboard);
    }
  }
}

Here we check if either the trigger or loading animations are using work areas to determine their start time, then applying them to the artboard accordingly.

If you're certain that you're not using a work area, you can simplify this to apply a time of 0 to the animations. However, checking for work areas makes this more robust, allowing us to drop in new files freely without encountering unexpected results.

Here's our refresh_controller.dart file in it's entirety...

import 'package:flutter/cupertino.dart';
import 'package:rive/rive.dart';

class RefreshController extends RiveAnimationController<RuntimeArtboard> {

  RuntimeArtboard _artboard;

  /// Our four different animations
  LinearAnimationInstance _idle;
  LinearAnimationInstance _pull;
  LinearAnimationInstance _trigger;
  LinearAnimationInstance _loading;

  /// Values from CupertinoSliverRefreshControl builder function
  RefreshIndicatorMode refreshState;
  double pulledExtent;
  double triggerThreshold;
  double refreshIndicatorExtent;

  double get _pullPos {
    return pulledExtent / triggerThreshold;
  }

  @override
  bool init(RuntimeArtboard artboard) {

    _artboard = artboard;
    _idle = getInstance(artboard, animationName: 'Idle');
    _pull = getInstance(artboard, animationName: 'Pull');
    _trigger = getInstance(artboard, animationName: 'Trigger');
    _loading = getInstance(artboard, animationName: 'Loading');

    _pull.time = _pull.animation.enableWorkArea
      ? _pull.animation.workEnd / _pull.animation.fps
      : _pull.animation.duration / _pull.animation.fps;

    isActive = true;
    return _idle != null;
  }

  LinearAnimationInstance getInstance(RuntimeArtboard artboard, { String animationName }) {
    var animation = artboard.animations.firstWhere(
      (animation) =>
          animation is LinearAnimation && animation.name == animationName,
      orElse: () => null,
    );
    if (animation != null) {
      return LinearAnimationInstance(animation as LinearAnimation);
    }
    return null;
  }

  @override
  void apply(RuntimeArtboard artboard, double elapsedSeconds) {

    // Idle animation
    _idle.animation.apply(_idle.time, coreContext: artboard);
    _idle.advance(elapsedSeconds);

    // Pull animation
    if (_trigger.time == 0) {
      _pull.animation.apply(_pull.time * _pullPos, coreContext: artboard);
    }

    // Trigger animation
    if (refreshState == RefreshIndicatorMode.refresh ||
        refreshState == RefreshIndicatorMode.armed) { 
        
      _trigger.animation.apply(_trigger.time, coreContext: artboard);
      _trigger.advance(elapsedSeconds);
      
      // Loading animation
      if (_trigger.time >= _trigger.animation.workEnd / _trigger.animation.fps) {
        _loading.animation.apply(_loading.time, coreContext: artboard);
        _loading.advance(elapsedSeconds);
      }
    }
  }

  void reset() {
    if (pulledExtent != null && triggerThreshold != null) {
      if (pulledExtent < triggerThreshold) {

        final triggerStartFrame = _trigger.animation.enableWorkArea ? _trigger.animation.workStart : 0;
        _trigger.time = triggerStartFrame.toDouble() / _trigger.animation.fps;

        final loadingStartFrame = _loading.animation.enableWorkArea ? _loading.animation.workStart : 0;
        _loading.time = loadingStartFrame.toDouble() / _loading.animation.fps;
        
        _loading.animation.apply(_loading.time, coreContext: _artboard);
        _trigger.animation.apply(_trigger.time, coreContext: _artboard);
        _pull.animation.apply(0, coreContext: _artboard);
      }
    }
  }

  @override 
  void dispose() {}

  @override
  void onActivate() {}

  @override
  void onDeactivate() {}
}

Adding the UI

Now let's create the UI and link it up with our controller. Start by creating a new StatefulWidget...

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:rive/rive.dart';
import 'package:space_reload_flutter/refresh_controller.dart';

class RefreshControlDemo extends StatefulWidget {
  @override
  _RefreshControlDemoState createState() => _RefreshControlDemoState();
}

class _RefreshControlDemoState extends State<RefreshControlDemo> {

  Artboard _artboard;
  RefreshController _controller;

  @override
  void initState() {
    _loadRiveFile();
    super.initState();
  }

  /// Loads a Rive file
  void _loadRiveFile() async {
    final bytes = await rootBundle.load('assets/space_reload.riv');
    final file = RiveFile();
    if (file.import(bytes)) {
      setState(() => _artboard = file.mainArtboard
        ..addController(_controller = RefreshController()
      ));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('StarTrack'),
        backgroundColor: Color(0xFF342472)),
      backgroundColor: Color(0xFF342472),
      body: Container()
    );
  }
}

Beyond creating a new StatefulWidget, we've also imported the refresh_controller.dart file we created earlier, added a function to load the .riv file, and setup a simple Scaffold widget in the build method.

Now let's replace the placeholder Container widget in the Scaffold body with something a little more interesting...

// 1
NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    if (notification is ScrollEndNotification) {
      _controller.reset();
    }
    return true;
  },
  // 2
  child: CustomScrollView(
    slivers: [
	  // 3
      CupertinoSliverRefreshControl(
        refreshTriggerPullDistance: 240.0,
        refreshIndicatorExtent: 240.0,
        builder: buildRefreshWidget,
        onRefresh: () {
          return Future<void>.delayed(const Duration(seconds: 5))
            ..then<void>((_) {
              if (mounted) {
                setState((){});
              }
            });
        },
      ),
      // 4
      SliverSafeArea(
        top: false, // Top safe area is consumed by the navigation bar.
        sliver: SliverList(
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              return SizedBox(
                height: 150,
                child: Container(
                  margin: EdgeInsets.only(left: 10, right: 10, top: 10),
                  decoration: BoxDecoration(
                    color: widget.demo.tileColor,
                    borderRadius: BorderRadius.circular(5)
                  ),
                )
              );
            },
            childCount: 10,
          ),
        ),
      ),
    ],
  ),
)
  1. We use a NotificationListener to get scroll notifications, and call the reset method on our controller once a scroll has ended. You might recall that we included a check on the pulledExtent within reset, so we can be sure that it will only do so when the scroll is done collapsing the refresh header.
  2. We use a CustomScrollView widget that will accommodate our refresh header and the scroll content.
  3. The CupertinoSliverRefreshControl widget is perfect for housing our Rive animation. Start by assigning the desired height and trigger threshold to the provided parameters. We'll extract the builder function to keep things tidy, and add a delay onRefresh to demonstrate our animation. In reality, this is where you would fetch your data.
  4. We add a series of boxes within the CustomScrollView as a placeholder for what would be our page content.

All that's left is to add the extracted builder function for the CupertinoSliverRefreshControl...

Widget buildRefreshWidget(
    BuildContext context,
    RefreshIndicatorMode refreshState,
    double pulledExtent,
    double refreshTriggerPullDistance,
    double refreshIndicatorExtent) {

  _controller.refreshState = refreshState;
  _controller.pulledExtent = pulledExtent;
  _controller.triggerThreshold = refreshTriggerPullDistance;
  _controller.refreshIndicatorExtent = refreshIndicatorExtent;

  return _artboard != null
    ? Rive(
      artboard: _artboard,
      fit: BoxFit.cover,
      alignment: Alignment.center)
    : Container();
}

We pass the values provided by CupertinoSliverRefreshControl along to our controller, and return a Rive widget with our desired fit and alignment.

Here's the final refresh_demo.dart...

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:rive/rive.dart';
import 'package:space_reload_flutter/refresh_controller.dart';

class RefreshControlDemo extends StatefulWidget {
  @override
  _RefreshControlDemoState createState() => _RefreshControlDemoState();
}

class _RefreshControlDemoState extends State<RefreshControlDemo> {

  Artboard _artboard;
  RefreshController _controller;

  @override
  void initState() {
    _loadRiveFile();
    super.initState();
  }

  /// Loads a Rive file
  void _loadRiveFile() async {
    final bytes = await rootBundle.load('assets/space_reload.riv');
    final file = RiveFile();
    if (file.import(bytes)) {
      setState(() => _artboard = file.mainArtboard
        ..addController(_controller = RefreshController()
      ));
    }
  }

  Widget buildRefreshWidget(
      BuildContext context,
      RefreshIndicatorMode refreshState,
      double pulledExtent,
      double refreshTriggerPullDistance,
      double refreshIndicatorExtent) {

    _controller.refreshState = refreshState;
    _controller.pulledExtent = pulledExtent;
    _controller.triggerThreshold = refreshTriggerPullDistance;
    _controller.refreshIndicatorExtent = refreshIndicatorExtent;

    return _artboard != null
      ? Rive(
        artboard: _artboard,
        fit: BoxFit.cover,
        alignment: Alignment.center)
      : Container();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('StarTrack'),
        backgroundColor: Color(0xFF342472)),
      backgroundColor: Color(0xFF342472),
      body: NotificationListener<ScrollNotification>(
        onNotification: (notification) {
          if (notification is ScrollEndNotification) {
            _controller.reset();
          }
          return true;
        },
        child: CustomScrollView(
          slivers: [
            CupertinoSliverRefreshControl(
              refreshTriggerPullDistance: 240.0,
              refreshIndicatorExtent: 240.0,
              builder: buildRefreshWidget,
              onRefresh: () {
                return Future<void>.delayed(const Duration(seconds: 5))
                  ..then<void>((_) {
                    if (mounted) {
                      setState((){});
                    }
                  });
              },
            ),
            SliverSafeArea(
              top: false, // Top safe area is consumed by the navigation bar.
              sliver: SliverList(
                delegate: SliverChildBuilderDelegate(
                  (BuildContext context, int index) {
                    return SizedBox(
                      height: 150,
                      child: Container(
                        margin: EdgeInsets.only(left: 10, right: 10, top: 10),
                        decoration: BoxDecoration(
                          color: Color(0xFF4A3F8A),
                          borderRadius: BorderRadius.circular(5)
                        ),
                      )
                    );
                  },
                  childCount: 10,
                ),
              ),
            ),
          ],
        ),
      )
    );
  }
}

Add our RefreshControlDemo widget to the main.dart file and we're all set!

import 'package:flutter/material.dart';
import 'package:space_reload_flutter/refresh_demo.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: RefreshControlDemo(),
    );
  }
}

Next steps

You can find the code for the Flutter project here.

Be sure to check out parts 2 and 3 if you're interested in implementing the pull-to-refresh animations in native iOS and Android!

Part 3 - iOS
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!