Flutter中Riverpod驱动的CustomPainter动画为何偶现跳步?
Riverpod + CustomPainter 动画异常问题修复
问题描述
尝试为从Riverpod Notifier获取数据的CustomPainter组件属性制作动画。修改第二个Item的数据触发Notifier后,ListTile前方的圆形本该平滑缩放,但值增大时动画正常,值减小时经常跳过部分步骤,动画实现存在问题。
原代码
// ignore_for_file: avoid_print import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp( const ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Riverpod + animated CustomPainter', home: const Example3(), theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.orange, brightness: MediaQueryData.fromWindow(WidgetsBinding.instance.window).platformBrightness, surface: Colors.deepOrange[600], ), ), ); } } class ItemPainter extends CustomPainter { final double value; ItemPainter(this.value); final itemPaint = Paint()..color = Colors.orange; @override void paint(Canvas canvas, Size size) { // draw a circle with a size depending on the value double radius = size.width / 10 * value / 2; canvas.drawCircle( Offset( size.width / 2, size.height / 2, ), radius, itemPaint, ); } @override bool shouldRepaint(covariant ItemPainter oldDelegate) => oldDelegate.value != value; } CustomPaint itemIcon(double value) { return CustomPaint( painter: ItemPainter(value), size: const Size(40, 40), ); } @immutable class Item { const Item({required this.id, required this.value}); final String id; final double value; } // notifier that provides a list of items class ItemsNotifier extends Notifier<List<Item>> { @override List<Item> build() { return [ const Item(id: 'A', value: 1.0), const Item(id: 'B', value: 5.0), const Item(id: 'C', value: 10.0), ]; } void randomize(String id) { // replace the state with a new list of items where the value is randomized from 0.0 to 10.0 state = [ for (final item in state) if (item.id == id) Item(id: item.id, value: Random().nextInt(100).toDouble() / 10.0) else item, ]; } } class AnimatedItem extends StatefulWidget { final Item item; const AnimatedItem(this.item, {super.key}); @override State<AnimatedItem> createState() => _AnimatedItemState(); } class _AnimatedItemState extends State<AnimatedItem> with SingleTickerProviderStateMixin { late final AnimationController _animationController; late Animation<double> animation; @override void initState() { super.initState(); _animationController = AnimationController( value: widget.item.value, vsync: this, duration: const Duration(milliseconds: 3000), ); } @override void dispose() { _animationController.dispose(); super.dispose(); } @override void didUpdateWidget(AnimatedItem oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.item.value != widget.item.value) { print('didUpdateWidget: ${oldWidget.item.value} -> ${widget.item.value}'); _animationController.value = oldWidget.item.value / 10; _animationController.animateTo(widget.item.value / 10); } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animationController, builder: (context, child) { return itemIcon((widget.item.value * _animationController.value)); }, ); } } final itemsProvider = NotifierProvider<ItemsNotifier, List<Item>>(() => ItemsNotifier()); class Example3 extends ConsumerWidget { const Example3({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final items = ref.watch(itemsProvider); return Scaffold( appBar: AppBar( title: const Text('Animated CustomPainter Problem'), ), // iterate over the item list in ItemsNotifier body: ListView.separated( separatorBuilder: (context, index) => const Divider(), itemCount: items.length, itemBuilder: (context, index) { final item = items.elementAt(index); return ListTile( key: Key(item.id), leading: AnimatedItem(item), title: Text('${item.value}'), ); }, ), floatingActionButton: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FloatingActionButton( onPressed: () { ref.read(itemsProvider.notifier).randomize('B'); // randomize the value of the second item }, child: const Icon(Icons.change_circle), ), ], ), ); } }
问题分析
- AnimationController初始化错误:将范围为0-10的
widget.item.value直接赋值给默认取值范围0.0~1.0的AnimationController,导致动画状态异常。 - 动画值计算逻辑错误:
build方法中用widget.item.value * _animationController.value计算缩放值,当item值减小时,计算结果完全偏离“从旧值过渡到新值”的预期。 - 无动画冲突处理:连续触发动画时,未中断当前动画,可能导致动画队列混乱。
修复代码
修改AnimatedItem组件的实现,使用Tween管理数值过渡,修正动画逻辑:
class AnimatedItem extends StatefulWidget { final Item item; const AnimatedItem(this.item, {super.key}); @override State<AnimatedItem> createState() => _AnimatedItemState(); } class _AnimatedItemState extends State<AnimatedItem> with SingleTickerProviderStateMixin { late final AnimationController _animationController; late Animation<double> _valueAnimation; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 3000), ); // 初始化动画,绑定当前item值 _valueAnimation = Tween<double>(begin: widget.item.value, end: widget.item.value).animate(_animationController); } @override void dispose() { _animationController.dispose(); super.dispose(); } @override void didUpdateWidget(AnimatedItem oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.item.value != widget.item.value) { print('didUpdateWidget: ${oldWidget.item.value} -> ${widget.item.value}'); // 停止当前动画,避免冲突 _animationController.stop(); // 更新Tween,从旧值过渡到新值 _valueAnimation = Tween<double>( begin: oldWidget.item.value, end: widget.item.value, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, )); // 重置并启动动画 _animationController.reset(); _animationController.forward(); } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _valueAnimation, builder: (context, child) { // 直接使用动画插值后的数值 return itemIcon(_valueAnimation.value); }, ); } }
优化说明
- 使用
Tween直接管理数值的起止范围,无需手动转换比例,逻辑更清晰。 - 添加
CurvedAnimation实现缓动过渡,动画更自然。 - 触发新动画前停止当前动画,避免连续点击导致的动画混乱。
内容的提问来源于stack exchange,提问作者Vindolin
相关产品推荐
相关产品推荐

