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

Flutter实现两个列表间带动画效果的元素跨列表移动功能

实现左右列表元素点击平移切换动画效果方案

核心实现思路

  • 把原有的普通ListView替换为AnimatedList,配合GlobalKey控制列表元素的增删动画,天然实现「剩余元素自动补位、目标列表自动腾空间」的需求
  • 点击元素时通过Overlay创建临时浮动元素,执行从原位置到目标列表的平移动画,模拟元素跨列表移动的视觉效果
  • 动画结束后同步更新两个列表的数据源即可

完整实现代码

import 'package:flutter/material.dart';

class AddToFave extends StatefulWidget {
  const AddToFave({Key? key}) : super(key: key);

  @override
  _AddToFaveState createState() => _AddToFaveState();
}

class _AddToFaveState extends State<AddToFave> with SingleTickerProviderStateMixin {
  final List<String> unselected = ['1','2','3','4','5','6','7','8','9','10'];
  final List<String> selected = [];

  // 两个AnimatedList的全局key,用来触发增删动画
  final GlobalKey<AnimatedListState> unselectedListKey = GlobalKey<AnimatedListState>();
  final GlobalKey<AnimatedListState> selectedListKey = GlobalKey<AnimatedListState>();

  late AnimationController _animationController;
  late double itemSize;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 提前计算元素尺寸,和你原来的定义保持一致
    itemSize = MediaQuery.of(context).size.width / 5;
  }

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

  // 处理从未选列表移动到已选列表的逻辑
  void _moveToSelected(int index, BuildContext context) async {
    final item = unselected[index];
    // 获取点击元素的全局位置
    final renderBox = context.findRenderObject() as RenderBox;
    final startPosition = renderBox.localToGlobal(Offset.zero);
    // 目标位置:左侧列表x坐标,y坐标是已选列表当前元素个数*元素高度
    final endPosition = Offset(0, selected.length * itemSize + MediaQuery.of(context).padding.top);

    // 创建Overlay临时元素,用来做平移动画
    late OverlayEntry overlayEntry;
    overlayEntry = OverlayEntry(
      builder: (context) => AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          final x = startPosition.dx + (endPosition.dx - startPosition.dx) * _animationController.value;
          final y = startPosition.dy + (endPosition.dy - startPosition.dy) * _animationController.value;
          return Positioned(
            left: x,
            top: y,
            child: _buildItem(item),
          );
        },
      ),
    );
    Overlay.of(context)?.insert(overlayEntry);

    // 触发原列表删除动画,目标列表插入动画
    unselected.removeAt(index);
    unselectedListKey.currentState?.removeItem(
      index,
      (context, animation) => SizeTransition(
        sizeFactor: animation,
        child: _buildItem(item),
      ),
    );
    selectedListKey.currentState?.insertItem(selected.length);

    // 执行位移动画
    await _animationController.forward(from: 0);
    // 动画结束更新数据源,移除临时元素
    setState(() {
      selected.add(item);
    });
    overlayEntry.remove();
  }

  // 处理从已选列表移动回未选列表的逻辑
  void _moveToUnselected(int index, BuildContext context) async {
    final item = selected[index];
    final renderBox = context.findRenderObject() as RenderBox;
    final startPosition = renderBox.localToGlobal(Offset.zero);
    final endPosition = Offset(MediaQuery.of(context).size.width - itemSize, unselected.length * itemSize + MediaQuery.of(context).padding.top);

    late OverlayEntry overlayEntry;
    overlayEntry = OverlayEntry(
      builder: (context) => AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          final x = startPosition.dx + (endPosition.dx - startPosition.dx) * _animationController.value;
          final y = startPosition.dy + (endPosition.dy - startPosition.dy) * _animationController.value;
          return Positioned(
            left: x,
            top: y,
            child: _buildItem(item),
          );
        },
      ),
    );
    Overlay.of(context)?.insert(overlayEntry);

    selected.removeAt(index);
    selectedListKey.currentState?.removeItem(
      index,
      (context, animation) => SizeTransition(
        sizeFactor: animation,
        child: _buildItem(item),
      ),
    );
    unselectedListKey.currentState?.insertItem(unselected.length);

    await _animationController.forward(from: 0);
    setState(() {
      unselected.add(item);
    });
    overlayEntry.remove();
  }

  // 抽离公共的元素样式,和你原来的样式保持一致
  Widget _buildItem(String text) {
    return Container(
      width: itemSize,
      height: itemSize,
      decoration: BoxDecoration(
        color: Colors.black,
        borderRadius: BorderRadius.circular(itemSize),
      ),
      child: Center(
        child: Text(
          text,
          style: const TextStyle(color: Colors.white),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            // 左侧已选列表
            SizedBox(
              width: itemSize,
              height: MediaQuery.of(context).size.height,
              child: AnimatedList(
                key: selectedListKey,
                initialItemCount: selected.length,
                itemBuilder: (context, index, animation) {
                  return InkWell(
                    onTap: () => _moveToUnselected(index, context),
                    child: SizeTransition(
                      sizeFactor: animation,
                      child: _buildItem(selected[index]),
                    ),
                  );
                },
              ),
            ),
            // 右侧未选列表
            SizedBox(
              width: itemSize,
              height: MediaQuery.of(context).size.height,
              child: AnimatedList(
                key: unselectedListKey,
                initialItemCount: unselected.length,
                itemBuilder: (context, index, animation) {
                  return InkWell(
                    onTap: () => _moveToSelected(index, context),
                    child: SizeTransition(
                      sizeFactor: animation,
                      child: _buildItem(unselected[index]),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

关键逻辑说明

  • SizeTransition配合AnimatedList直接实现了列表元素增删时的高度过渡,无需额外处理自动补位、腾空间的效果
  • 用Overlay做的临时浮动元素只会在动画过程中存在,不会影响原有列表的结构
  • 左右双向移动的逻辑对称,点击已选列表的元素也会自动移回未选列表,符合通用交互逻辑

内容的提问来源于stack exchange,提问作者Morteza Mohammadi

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.01 23:06:05