Flutter中实现Hero Page Route效果


在移动应用开发中,优雅的页面转场动画能够显著提升用户体验。流畅自然的动画和直观的交互手势已成为现代移动应用的标配。本文将详细介绍如何在Flutter中实现一个功能完整的Hero页面路由效果,包括拖拽缩放关闭、手势识别等功能。

效果预览

要实现的效果具有以下特性:

  • 双向拖拽关闭:支持垂直和水平方向的拖拽手势
  • 页面缩放:页面不移动位置,只根据拖拽距离进行缩放
  • 速度检测:支持快速滑动关闭
  • 物理阻力:拖拽阻力效果,越拖越难拖
  • 弹簧动画:自然的回弹效果
  • 实时反馈:透明度和缩放的实时变化

效果类似于App Store中卡片显示关闭那种效果。

核心实现

1. 自定义PageRouteBuilder

首先,创建一个继承自PageRouteBuilder的自定义路由类:

dart 复制代码
/// 扩展的Hero页面路由,提供拖拽缩放关闭的转场动画
class InteractiveHeroRoute<T> extends PageRouteBuilder<T> {
  final Widget child;
  final String? heroTag;
  final bool enableDragToClose;
  final VoidCallback? onDragStart;
  final VoidCallback? onDragEnd;

  InteractiveHeroRoute({
    required this.child,
    this.heroTag,
    this.enableDragToClose = true,
    this.onDragStart,
    this.onDragEnd,
    super.settings,
  }) : super(
          pageBuilder: (context, animation, secondaryAnimation) {
            if (enableDragToClose) {
              return DraggablePageWrapper(
                onDragStart: onDragStart,
                onDragEnd: onDragEnd,
                debugMode: kDebugMode, // 在调试模式下自动启用调试
                child: child,
              );
            }
            return child;
          },
          transitionDuration: const Duration(milliseconds: 400),
          reverseTransitionDuration: const Duration(milliseconds: 350),
          opaque: false,
        );
}

2. 转场动画实现

重写buildTransitions方法来实现自定义的转场动画:

dart 复制代码
@override
Widget buildTransitions(
  BuildContext context,
  Animation<double> animation,
  Animation<double> secondaryAnimation,
  Widget child,
) {
  // 弹簧动画曲线,提供更自然的感觉
  final curvedAnimation = CurvedAnimation(
    parent: animation,
    curve: Curves.fastOutSlowIn,
    reverseCurve: Curves.easeInCubic,
  );

  // 页面缩放动画 - 从0.9缩放到1.0
  final scaleAnimation = Tween<double>(
    begin: 0.9,
    end: 1.0,
  ).animate(curvedAnimation);

  // 页面透明度动画
  final opacityAnimation = Tween<double>(
    begin: 0.0,
    end: 1.0,
  ).animate(CurvedAnimation(
    parent: animation,
    curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
  ));

  return Stack(
    children: [
      // 背景遮罩层
      FadeTransition(
        opacity: opacityAnimation,
        child: Container(
          color: Colors.black.withOpacity(0.2),
        ),
      ),
      // 主要内容
      FadeTransition(
        opacity: opacityAnimation,
        child: ScaleTransition(
          scale: scaleAnimation,
          child: child,
        ),
      ),
    ],
  );
}

3. 拖拽手势处理

DraggablePageWrapper是实现拖拽功能的核心组件:

dart 复制代码
/// 包装器Widget,支持拖拽关闭手势
class DraggablePageWrapper extends StatefulWidget {
  final Widget child;
  final VoidCallback? onDragStart;
  final VoidCallback? onDragEnd;
  final bool debugMode;

  const DraggablePageWrapper({
    super.key,
    required this.child,
    this.onDragStart,
    this.onDragEnd,
    this.debugMode = false,
  });

  @override
  State<DraggablePageWrapper> createState() => _DraggablePageWrapperState();
}

4. 手势识别

实现拖拽识别和速度检测:

dart 复制代码
void _handleDragUpdate(Offset delta) {
  if (!_isDragging) return;

  final now = DateTime.now();
  final timeDelta = now.difference(_lastUpdateTime).inMilliseconds;

  // 记录速度数据用于快速滑动检测
  if (timeDelta > 0) {
    final velocity = Offset(
      delta.dx / timeDelta * 1000, // 转换为像素/秒
      delta.dy / timeDelta * 1000,
    );

    _velocityBuffer.add(velocity);
    if (_velocityBuffer.length > _maxVelocitySamples) {
      _velocityBuffer.removeAt(0);
    }
    _lastUpdateTime = now;
  }

  // 如果拖拽delta太小,直接返回避免微小抖动
  if (delta.distance < 0.5) return;

  setState(() {
    final screenWidth = MediaQuery.of(context).size.width;
    final screenHeight = MediaQuery.of(context).size.height;

    // 添加阻力效果 - 越拖越难拖
    final resistanceFactor =
        (1 - (_dragOffsetY.abs() + _dragOffsetX.abs()) / (screenHeight * 2))
            .clamp(0.2, 1.0);

    _dragOffsetY += delta.dy * resistanceFactor;
    _dragOffsetX += delta.dx * resistanceFactor;

    // 限制拖动范围
    _dragOffsetY = _dragOffsetY.clamp(-screenHeight * 0.4, screenHeight * 0.4);
    _dragOffsetX = _dragOffsetX.clamp(-screenWidth * 0.4, screenWidth * 0.4);
  });
}

5. 关闭条件判断

实现基于距离和速度的双重关闭条件:

dart 复制代码
void _handleDragEnd() {
  _isDragging = false;
  _scaleController.reverse();
  widget.onDragEnd?.call();

  final screenSize = MediaQuery.of(context).size;
  final dismissThresholdDistance = screenSize.height * _kDismissThresholdRatio;

  // 计算平均速度
  Offset avgVelocity = Offset.zero;
  if (_velocityBuffer.isNotEmpty) {
    final sum = _velocityBuffer.fold<Offset>(
      Offset.zero,
      (prev, curr) => prev + curr,
    );
    avgVelocity = sum / _velocityBuffer.length.toDouble();
  }

  // 基于拖拽距离的关闭条件 - 总拖拽距离
  final totalDistance = Offset(_dragOffsetX, _dragOffsetY).distance;
  final shouldDismissByDistance = totalDistance > dismissThresholdDistance;

  // 基于速度的关闭条件(快速滑动)
  final totalVelocity = avgVelocity.distance;
  final shouldDismissByVelocity = totalVelocity > _kFlingDismissVelocity;

  if (shouldDismissByDistance || shouldDismissByVelocity) {
    Navigator.of(context).pop();
  } else {
    // 弹回原位置,使用弹簧动画
    _animateToOrigin();
  }
}

6. 弹簧回弹动画

当用户拖拽但未达到关闭条件时,实现自然的回弹效果:

dart 复制代码
/// 弹簧动画回到原位置
void _animateToOrigin() {
  // _dragController 的值会从 0.0 -> 1.0
  // 用一个从 1.0 -> 0.0 的 Tween,使得动画效果是从当前位置回到原点
  final animation = _dragController.drive(
    Tween<double>(begin: 1.0, end: 0.0).chain(
      CurveTween(curve: Curves.easeOutCubic),
    ),
  );

  final initialOffsetY = _dragOffsetY;
  final initialOffsetX = _dragOffsetX;

  // 在动画过程中,不断更新 offset,使其趋近于 0
  animation.addListener(() {
    if (mounted) {
      setState(() {
        _dragOffsetY = initialOffsetY * animation.value;
        _dragOffsetX = initialOffsetX * animation.value;
      });
    }
  });

  // 重置控制器并从头开始播放动画
  _dragController.forward(from: 0.0);
}

7. 动态缩放效果

实现页面的动态缩放效果,不移动位置只进行缩放:

dart 复制代码
@override
Widget build(BuildContext context) {
  return GestureDetector(
    onPanStart: (_) => _handleDragStart(),
    onPanUpdate: (details) => _handleDragUpdate(details.delta),
    onPanEnd: (_) => _handleDragEnd(),
    child: AnimatedBuilder(
      animation: Listenable.merge([_scaleAnimation, _dragController]),
      builder: (context, child) {
        final screenSize = MediaQuery.of(context).size;
        final dismissThresholdDistance =
            screenSize.height * _kDismissThresholdRatio;

        // 计算拖拽距离,使用 Offset.distance 更符合直觉
        final dragDistance = Offset(_dragOffsetX, _dragOffsetY).distance;

        // 根据拖拽距离计算缩放进度
        final dragProgress =
            (dragDistance / dismissThresholdDistance).clamp(0.0, 1.0);

        // 基础缩放(拖拽开始时的缩放)+ 拖拽缩放
        final dragScale = (1.0 - dragProgress * 0.15).clamp(0.92, 1.0);
        final finalScale = _scaleAnimation.value * dragScale;

        // 只有缩放效果,不移动位置
        return Transform.scale(
          scale: finalScale,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(30.r),
            child: widget.child,
          ),
        );
      },
      child: widget.child,
    ),
  );
}

使用方法

使用这个自定义路由非常简单:

dart 复制代码
// 基本使用
Navigator.push(context, InteractiveHeroRoute(
  heroTag: 'unique_tag',
  enableDragToClose: true,
  child: DetailPage(),
));

// 带回调的使用
Navigator.push(context, InteractiveHeroRoute(
  heroTag: 'product_${product.id}',
  enableDragToClose: true,
  onDragStart: () => print('开始拖拽'),
  onDragEnd: () => print('拖拽结束'),
  child: ProductDetailPage(product: product),
));

关键参数配置

dart 复制代码
/// 基于屏幕高度的拖拽关闭距离阈值 (10% 的屏幕高度)
static const _kDismissThresholdRatio = 0.1;

/// 快速滑动的关闭速度阈值 (像素/秒)
static const _kFlingDismissVelocity = 800.0;

/// 最大速度采样数量
final int _maxVelocitySamples = 5;

调试功能

代码中内置了调试功能,在Debug模式下会输出详细的拖拽参数:

dart 复制代码
if (widget.debugMode && kDebugMode) {
  debugPrint('''
[DragDebug] 拖拽结束参数:
  位置偏移: X=${_dragOffsetX.toStringAsFixed(1)}, Y=${_dragOffsetY.toStringAsFixed(1)}
  总拖拽距离: ${totalDistance.toStringAsFixed(1)} (阈值: ${dismissThresholdDistance.toStringAsFixed(1)})
  平均速度: ${totalVelocity.toStringAsFixed(1)} (阈值: $_kFlingDismissVelocity)
  关闭条件: 
    - 拖拽距离: $shouldDismissByDistance
    - 滑动速度: $shouldDismissByVelocity
      ''');
}

总结

通过以上实现,成功创建了一个功能完整的交互式Hero页面路由效果。这个实现不仅提供了流畅的视觉体验,还具备了手势识别和物理感知的交互反馈。

这种实现方式可以应用于各种需要优雅页面转场的场景,为用户提供更加流畅和直观的交互体验。无论是商品、卡片详情页,图片预览还是其他全屏展示场景,都能很好地提升应用的用户体验。

相关推荐
神经骚栋3 小时前
Flutter面试题01-Flutter中的三棵树
flutter·面试
小严家1 天前
Flutter完整开发指南 | Flutter&Dart – The Complete Guide
开发语言·flutter
倾云鹤1 天前
flutter实现Function Call
flutter·llm·function call
程序员老刘·2 天前
Flutter版本选择指南:避坑3.27 | 2025年9月
flutter·跨平台开发·客户端开发
懒得不想起名字2 天前
Flutter二维码的生成和扫描
flutter
鹏多多2 天前
flutter-详解控制组件显示的两种方式Offstage与Visibility
前端·flutter
猪哥帅过吴彦祖2 天前
Flutter 系列教程:常用基础组件 (上) - `Text`, `Image`, `Icon`, `Button`
android·flutter·ios
恋猫de小郭2 天前
Fluttercon EU 2025 :Let's go far with Flutter
android·前端·flutter