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

如何在Flutter中实现类似cariocamenu的滚动圆形框轨迹动画?

在Flutter中实现滚动跟随的圆形轨迹指示器动画

完全可以实现这种效果,核心是通过滚动监听获取位置,结合自定义绘制和动画过渡来完成,以下是具体实现方案:

核心思路

  1. 监听滚动位置:用ScrollController捕获列表的滚动偏移,计算出当前需要高亮的列表项位置。
  2. 平滑动画过渡:通过AnimationController和Tween实现指示器在不同位置间的平滑移动,避免生硬跳转。
  3. 自定义绘制轨迹:使用CustomPainter绘制垂直轨迹线、圆形指示器,以及可选的轨迹节点。

完整代码实现

import 'package:flutter/material.dart';

class CircularTrailIndicator extends StatefulWidget {
  const CircularTrailIndicator({super.key});

  @override
  State<CircularTrailIndicator> createState() => _CircularTrailIndicatorState();
}

class _CircularTrailIndicatorState extends State<CircularTrailIndicator> with SingleTickerProviderStateMixin {
  final ScrollController _scrollController = ScrollController();
  late AnimationController _animationController;
  late Animation<double> _positionAnimation;
  double _targetY = 0;
  // 可自定义的参数
  final double _itemHeight = 60;
  final double _indicatorRadius = 12;
  final double _trailWidth = 2;
  final int _listItemCount = 10;

  @override
  void initState() {
    super.initState();
    // 初始化动画控制器
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _positionAnimation = Tween<double>(begin: 0, end: 0).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
    );

    // 监听滚动事件,更新指示器目标位置
    _scrollController.addListener(() {
      final scrollOffset = _scrollController.offset;
      // 计算当前对应的列表项索引,限制在合法范围内
      final targetIndex = (scrollOffset / _itemHeight).round().clamp(0, _listItemCount - 1);
      // 计算指示器目标Y坐标(列表项中心位置)
      setState(() {
        _targetY = targetIndex * _itemHeight + _itemHeight / 2;
      });
      // 启动动画,从当前位置过渡到目标位置
      _animationController.forward(from: 0);
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          // 左侧轨迹与指示器区域
          AnimatedBuilder(
            animation: _positionAnimation,
            builder: (context, child) {
              // 插值计算当前指示器的Y坐标
              final currentY = lerpDouble(_positionAnimation.value, _positionAnimation.value, _targetY)!;
              return CustomPaint(
                size: const Size(40, double.infinity),
                painter: _TrailPainter(
                  currentY: currentY,
                  indicatorRadius: _indicatorRadius,
                  trailWidth: _trailWidth,
                  itemCount: _listItemCount,
                  itemHeight: _itemHeight,
                ),
              );
            },
          ),
          // 右侧滚动列表
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: _listItemCount,
              itemBuilder: (context, index) {
                return SizedBox(
                  height: _itemHeight,
                  child: Center(child: Text('列表项 $index')),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

// 自定义画笔,负责绘制轨迹和指示器
class _TrailPainter extends CustomPainter {
  final double currentY;
  final double indicatorRadius;
  final double trailWidth;
  final int itemCount;
  final double itemHeight;

  _TrailPainter({
    required this.currentY,
    required this.indicatorRadius,
    required this.trailWidth,
    required this.itemCount,
    required this.itemHeight,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final centerX = size.width / 2;
    // 绘制主轨迹线
    final trailPaint = Paint()
      ..color = Colors.blue
      ..strokeWidth = trailWidth
      ..style = PaintingStyle.stroke;
    canvas.drawLine(
      Offset(centerX, indicatorRadius),
      Offset(centerX, size.height - indicatorRadius),
      trailPaint,
    );

    // 绘制圆形指示器
    final indicatorPaint = Paint()..color = Colors.blue;
    canvas.drawCircle(
      Offset(centerX, currentY),
      indicatorRadius,
      indicatorPaint,
    );

    // 绘制轨迹上的白色节点(可选,增强视觉层次)
    final nodePaint = Paint()
      ..color = Colors.white
      ..strokeWidth = 2;
    for (int i = 0; i < itemCount; i++) {
      final nodeY = i * itemHeight + itemHeight / 2;
      canvas.drawCircle(Offset(centerX, nodeY), 4, nodePaint);
    }
  }

  @override
  bool shouldRepaint(covariant _TrailPainter oldDelegate) {
    // 仅当指示器位置变化时重绘
    return oldDelegate.currentY != currentY;
  }
}

自定义调整点

  • 修改_itemHeight适配你的列表项高度;
  • 调整_indicatorRadius、_trailWidth改变指示器和轨迹的尺寸;
  • 替换Colors.blue为你需要的主题色;
  • 若列表项高度不固定,可以通过RenderObject获取每个列表项的实际位置,替代固定高度计算。

内容的提问来源于stack exchange,提问作者Ash Khachatryan

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.22 16:39:46