You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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),
          ),
        ],
      ),
    );
  }
}

问题分析

  1. AnimationController初始化错误:将范围为0-10的widget.item.value直接赋值给默认取值范围0.0~1.0的AnimationController,导致动画状态异常。
  2. 动画值计算逻辑错误:build方法中用widget.item.value * _animationController.value计算缩放值,当item值减小时,计算结果完全偏离“从旧值过渡到新值”的预期。
  3. 无动画冲突处理:连续触发动画时,未中断当前动画,可能导致动画队列混乱。

修复代码

修改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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 16:25:25