如何实现Flutter中Widget沿直线移动到另一Widget位置?
实现Flutter Chip平滑移动动画(类似多邻国效果)
要实现点击Chip后平滑移动到另一个容器的效果,核心思路是通过Overlay绘制临时动画组件,先完成位移动画,再更新原列表数据。以下是修改后的完整代码,包含详细注释:
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; void main() { runApp(const MyApp()); } class MyAppState with ChangeNotifier {} class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => MyAppState(), child: MaterialApp( title: 'FormublocksBeta1', theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange), ), home: const Formulas(), ), ); } } class Formulas extends StatefulWidget { const Formulas({super.key}); @override State<Formulas> createState() => _FormulasState(); } class _FormulasState extends State<Formulas> with SingleTickerProviderStateMixin { List<String> draggedWords = []; List<String> words = ["I", "love", "Flutter"]; // 存储每个Chip的GlobalKey,用于获取组件位置 final Map<String, GlobalKey> _chipKeys = {}; AnimationController? _animationController; Animation<Offset>? _offsetAnimation; OverlayEntry? _overlayEntry; String? _animatingWord; Size? _chipSize; @override void initState() { super.initState(); // 为每个初始word生成GlobalKey for (var word in words) { _chipKeys[word] = GlobalKey(); } } @override void dispose() { _animationController?.dispose(); _overlayEntry?.remove(); super.dispose(); } // 获取组件在屏幕上的绝对位置 Offset _getWidgetPosition(GlobalKey key) { final renderBox = key.currentContext?.findRenderObject() as RenderBox; return renderBox.localToGlobal(Offset.zero); } // 获取组件尺寸 Size _getWidgetSize(GlobalKey key) { final renderBox = key.currentContext?.findRenderObject() as RenderBox; return renderBox.size; } // 创建Overlay中的动画Chip OverlayEntry _createOverlayEntry(Offset startOffset, Offset endOffset) { return OverlayEntry( builder: (context) => Positioned( left: startOffset.dx, top: startOffset.dy, width: _chipSize?.width, height: _chipSize?.height, child: SlideTransition( position: _offsetAnimation!, child: Chip( label: Text(_animatingWord!), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onPrimary), backgroundColor: Theme.of(context).colorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(110), side: const BorderSide(color: Colors.transparent), ), ), ), ), ); } // 执行从下方到上方容器的动画 void _animateToTop(String word) { final startKey = _chipKeys[word]!; final startOffset = _getWidgetPosition(startKey); _chipSize = _getWidgetSize(startKey); // 计算上方容器的目标位置(容器中心减去Chip一半尺寸) final screenWidth = MediaQuery.of(context).size.width; final screenHeight = MediaQuery.of(context).size.height; final topContainerOffset = Offset( (screenWidth - 300) / 2 + 150 - (_chipSize!.width / 2), (screenHeight * 3/4 - 300)/2 + 150 - (_chipSize!.height / 2), ); // 初始化动画控制器 _animationController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); // 创建位移动画 _offsetAnimation = Tween<Offset>( begin: Offset.zero, end: Offset( (topContainerOffset.dx - startOffset.dx) / _chipSize!.width, (topContainerOffset.dy - startOffset.dy) / _chipSize!.height, ), ).animate(CurvedAnimation( parent: _animationController!, curve: Curves.easeInOut, )); _animatingWord = word; _overlayEntry = _createOverlayEntry(startOffset, topContainerOffset); Overlay.of(context).insert(_overlayEntry!); // 启动动画,结束后更新列表并清理资源 _animationController!.forward().whenComplete(() { setState(() { words.remove(word); draggedWords.add(word); // 为新加入的word添加GlobalKey _chipKeys[word] = GlobalKey(); }); _overlayEntry?.remove(); _animationController?.dispose(); _animatingWord = null; }); } // 执行从上方到下方容器的动画 void _animateToBottom(String word) { final startKey = _chipKeys[word]!; final startOffset = _getWidgetPosition(startKey); _chipSize = _getWidgetSize(startKey); // 计算下方Row的目标位置(屏幕水平中心,垂直区域中心) final screenWidth = MediaQuery.of(context).size.width; final screenHeight = MediaQuery.of(context).size.height; final bottomRowOffset = Offset( screenWidth / 2 - (_chipSize!.width / 2), screenHeight * 3/4 + (screenHeight /4)/2 - (_chipSize!.height /2), ); _animationController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _offsetAnimation = Tween<Offset>( begin: Offset.zero, end: Offset( (bottomRowOffset.dx - startOffset.dx) / _chipSize!.width, (bottomRowOffset.dy - startOffset.dy) / _chipSize!.height, ), ).animate(CurvedAnimation( parent: _animationController!, curve: Curves.easeInOut, )); _animatingWord = word; _overlayEntry = _createOverlayEntry(startOffset, bottomRowOffset); Overlay.of(context).insert(_overlayEntry!); _animationController!.forward().whenComplete(() { setState(() { draggedWords.remove(word); words.add(word); _chipKeys[word] = GlobalKey(); }); _overlayEntry?.remove(); _animationController?.dispose(); _animatingWord = null; }); } @override Widget build(BuildContext context) { return Scaffold( body: Column( children: [ Expanded( flex: 3, child: Center( child: Container( width: 300, height: 300, color: Theme.of(context).colorScheme.surfaceContainer, child: Center( child: Wrap( children: draggedWords.map((word) { // 给每个Chip绑定GlobalKey _chipKeys.putIfAbsent(word, () => GlobalKey()); return GestureDetector( onTap: () => _animateToBottom(word), child: Chip( key: _chipKeys[word], label: Text(word), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onPrimary), backgroundColor: Theme.of(context).colorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(110), side: const BorderSide(color: Colors.transparent), ), ), ); }).toList(), ), ), ), ), ), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: words.map((word) { _chipKeys.putIfAbsent(word, () => GlobalKey()); return GestureDetector( onTap: () => _animateToTop(word), child: Chip( key: _chipKeys[word], label: Text(word), labelStyle: TextStyle(color: Theme.of(context).colorScheme.onPrimary), backgroundColor: Theme.of(context).colorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(110), side: const BorderSide(color: Colors.transparent), ), ), ); }).toList(), ), ), ], ), ); } }
关键部分说明:
- GlobalKey管理:用
Map<String, GlobalKey>为每个Chip分配唯一Key,用于获取组件的屏幕位置和尺寸。 - Overlay动画:点击时创建
OverlayEntry,在屏幕顶层绘制一个临时Chip,通过SlideTransition执行位移动画。 - 动画控制器:使用
AnimationController和Tween控制动画时长和位移曲线,这里用Curves.easeInOut实现自然的缓动效果。 - 状态更新时机:动画完成后再更新
draggedWords和words列表,避免瞬移现象。
你可以根据需求调整动画时长、目标位置计算方式,或者添加缩放、透明度等额外动画效果,让移动过程更接近多邻国的交互体验。
内容的提问来源于stack exchange,提问作者Dasterman800
相关产品推荐
相关产品推荐

