Rendering glitch when rive animation comes into view during pull to refresh animation
Hi! I've run into a weird visual glitch when using a rive animation inside of a CupertinoSliverRefreshControl.
See the linked video, and focus on the gap that opens when pulling the list. https://github.com/rive-app/rive-flutter/assets/79078/72bc38c0-48d4-4166-8626-0bd34d884851
It might be hard to see, but if you look closely, the handle of the tennis racket is drawn for a few frames before the canvas is cleared. It seems like what's happening is that the "default state" of the state machine is drawn for one frame before the current animation is applied. This effect appears regardless if I'm updating the state machine to match the pull extent of CupertinoSliverRefreshControl.
Note: The glitch does not appear if you don't set an active state machine for the RiveAnimation.
To reproduce the bug, check out this repo: https://github.com/andreasmpet/rive_pull_to_refresh
Hi @andreasmpet , thanks for the detailed issue and sample.
You're correct that with a state machine the first frame will show the default view before transitioning into the first state. We'll chat internally to see if this can be improved to ensure better default behaviour.
One thing that you can do as a solution for now is to manually advance the artboard slightly yourself (before drawing anything). For example:
artboard.advance(1 / 60, nested: true); // 1 frame at 60fps
Or you could modify this within the editor to make sure the positions are in a similar position in the Design state.
This will resolve your issue where the first frame of the state machine is wrong. But there will be a different issue for your example, as the RiveAnimation widget needs to be recreated each time the pull to refresh resets.
A small side effect of using the RiveAnimation widget is that there are some futures within the widget responsible for loading the Rive file, instancing a new artboard, and attaching input listeners. All of this can result in frames where nothing is rendered.
It'll be faster to make use of the Rive widget and pass in an artboard yourself. This will require you to create a new instance from the artboard each time the pull to refresh starts (when the animation is recreated) to ensure that the animations and state machine resets.
I've attached a modified example to show how this can work. Also note that you can directly set the value on an SMI input, such as:
_pullAmountInputHandle?.value = pullPercentage;
We also recently released a complete tutorial on pull-to-refresh with a Flutter sample/video - that shows an alternative way to set this up. Though I think I actually prefer the code below (from an artboard instancing perspective, this will be the fastest/best). You can find the tutorials here: https://rive.app/use-cases/pull-to-refresh
Full example:
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:rive/rive.dart';
const List<String> playerNames = [
"Edvard Quickman",
"Eva Dupree",
"Abe Lincoln",
"Ebba Lindgren",
"Trixxy Liz",
"Mr. Umbrella",
"Sir Ocelot",
"Tinman",
"Casper Ruud",
];
void main() async {
// Just setting the locale for date formatting.
await initializeDateFormatting('en_GB', null);
runApp(const MyApp());
}
class TennisMatch {
final String playerA;
final String playerB;
final DateTime dateTime;
TennisMatch(this.playerA, this.playerB, this.dateTime);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF23646D)),
useMaterial3: true,
),
home: const PullToRefreshPlaygroundPage(),
);
}
}
class PullToRefreshPlaygroundPage extends StatefulWidget {
const PullToRefreshPlaygroundPage({super.key});
@override
State<PullToRefreshPlaygroundPage> createState() =>
_PullToRefreshPlaygroundPageState();
}
class _PullToRefreshPlaygroundPageState
extends State<PullToRefreshPlaygroundPage> {
static const Duration _refreshDuration = Duration(seconds: 3);
static const String _assetPath =
"assets/rive_animations/tennis_pull_to_refresh.riv";
static const String _stateMachineName = "Tennis Pull to Refresh";
static const String _pullAmountInputName = "Pull Down";
static const String _triggerInputName = "Trigger";
// Tweak this to tweak how much of the vertical height of the animation you need to pull before refreshing
static const double _animationTriggerAspectRatio = 0.5;
// Tweak this to tweak how much of the vertical height of the animation you want to show when it's loading
static const double _indicatorAspectRatio = 0.5;
StateMachineController? _stateMachineController;
RiveFile? _riveFile;
SMINumber? _pullAmountInputHandle;
SMITrigger? _triggerRefreshInputHandle;
late Artboard _artboard;
bool _isRefreshing = false;
List<TennisMatch>? _loadedMatches;
@override
void initState() {
super.initState();
_loadedMatches = _generateMatches();
_loadRiveFile();
}
void _loadRiveFile() async {
// Not handling any loading errors here on purpose since this is a playground app.
_riveFile = await RiveFile.asset(_assetPath);
// Get a refrerence to the artboard;
setState(() {
_artboard = _riveFile!.mainArtboard;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Tennis Schedule",
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary)),
backgroundColor: Theme.of(context).primaryColor),
body: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
_buildPullToRefreshIndicator(screenWidth: screenWidth),
_buildHeader(context, _isRefreshing),
_buildScheduleList(context, _loadedMatches ?? []),
]);
},
),
);
}
SliverToBoxAdapter _buildHeader(BuildContext context, bool isRefreshing) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return SliverToBoxAdapter(
child: AnimatedSlide(
offset: isRefreshing ? const Offset(0, -1) : Offset.zero,
duration: const Duration(milliseconds: 300),
child:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Container(
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"Pull to refresh",
style: textTheme.titleMedium!
.copyWith(color: colorScheme.onSecondary),
textAlign: TextAlign.center,
),
),
),
]),
),
);
}
Future<void> _onRefresh() async {
setState(() {
_isRefreshing = true;
});
// Tell the state machine that we're gonna fire off the trigger animation
// _stateMachineController!.setInputValue(_triggerRefreshInputHandle.id, true);
_triggerRefreshInputHandle?.fire();
await Future.delayed(_refreshDuration);
setState(() {
_isRefreshing = false;
_loadedMatches = _generateMatches();
});
}
Widget _buildPullToRefreshIndicator({required double screenWidth}) {
final riveFile = _riveFile;
final triggerPullDistance = _animationTriggerAspectRatio * screenWidth;
final refreshIndicatorExtent = _indicatorAspectRatio * screenWidth;
if (riveFile == null) {
return SliverToBoxAdapter(child: Container());
}
// Using cupertino refresh controller because its default behavior is to keep taking up space in the list
// during refresh, which is what we want for this kind of "full scene" pull to refresh animation.
return CupertinoSliverRefreshControl(
onRefresh: _onRefresh,
refreshTriggerPullDistance: triggerPullDistance,
refreshIndicatorExtent: refreshIndicatorExtent,
builder: (context, refreshState, pulledExtent, refreshTriggerPullDistance,
refreshIndicatorExtent) {
final pullPercentage =
(pulledExtent / max(1, refreshIndicatorExtent)) * 100;
_pullAmountInputHandle?.value = pullPercentage;
return InstancedRiveArtboard(
artboard: _artboard,
onInit: (instancedArtboard) {
// We need to re-add the state machine controller and inputs every
//time we re-instance the artboard.
_stateMachineController = StateMachineController.fromArtboard(
instancedArtboard, _stateMachineName);
instancedArtboard.addController(_stateMachineController!);
_pullAmountInputHandle = _stateMachineController!
.findSMI<SMINumber>(_pullAmountInputName)!;
_triggerRefreshInputHandle = _stateMachineController!
.findSMI<SMITrigger>(_triggerInputName)!;
// Slighly advance the artboard to avoid the first frame being wrong
instancedArtboard.advance(1 / 60, nested: true);
},
onDispose: () {
_stateMachineController?.dispose();
},
);
},
);
}
List<TennisMatch> _generateMatches() {
final matches = <TennisMatch>[];
final candidates = List<String>.from(playerNames);
candidates.shuffle();
while (candidates.length > 2) {
final playerA = candidates.removeAt(0);
final playerB = candidates.removeAt(0);
matches.add(
TennisMatch(playerA, playerB,
DateTime.now().add(Duration(hours: candidates.length))),
);
}
return matches;
}
Widget _buildScheduleList(BuildContext context, List<TennisMatch> matches) {
final dateFormatter = DateFormat.yMMMMd().add_Hm();
return SliverList.separated(
itemBuilder: (context, index) {
final match = matches[index];
String formattedDate = dateFormatter.format(match.dateTime);
return ListTile(
title: Text(
"${match.playerA} vs. ${match.playerB}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(formattedDate),
);
},
separatorBuilder: (context, index) {
return const Divider(endIndent: 16, indent: 16);
},
itemCount: matches.length,
);
}
}
typedef InstancedArtboardCallback = void Function(Artboard instancedArtboard);
class InstancedRiveArtboard extends StatefulWidget {
const InstancedRiveArtboard({
super.key,
required this.artboard,
required this.onInit,
required this.onDispose,
});
final Artboard artboard;
final InstancedArtboardCallback onInit;
final VoidCallback onDispose;
@override
State<InstancedRiveArtboard> createState() => _InstancedRiveArtboardState();
}
class _InstancedRiveArtboardState extends State<InstancedRiveArtboard> {
late final Artboard localArtboard;
@override
void initState() {
super.initState();
localArtboard = widget.artboard.instance();
widget.onInit(localArtboard);
}
@override
void dispose() {
widget.onDispose.call();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Rive(
artboard: localArtboard,
fit: BoxFit.cover,
alignment: Alignment.center,
);
}
}
Closing this as there has been no additional information.