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

如何实现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(),
            ),
          ),
        ],
      ),
    );
  }
}

关键部分说明:

  1. GlobalKey管理:用Map<String, GlobalKey>为每个Chip分配唯一Key,用于获取组件的屏幕位置和尺寸。
  2. Overlay动画:点击时创建OverlayEntry,在屏幕顶层绘制一个临时Chip,通过SlideTransition执行位移动画。
  3. 动画控制器:使用AnimationController和Tween控制动画时长和位移曲线,这里用Curves.easeInOut实现自然的缓动效果。
  4. 状态更新时机:动画完成后再更新draggedWords和words列表,避免瞬移现象。

你可以根据需求调整动画时长、目标位置计算方式,或者添加缩放、透明度等额外动画效果,让移动过程更接近多邻国的交互体验。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 15:24:49