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
相关产品推荐
相关产品推荐

