
在移动应用开发中,优雅的页面转场动画能够显著提升用户体验。流畅自然的动画和直观的交互手势已成为现代移动应用的标配。本文将详细介绍如何在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页面路由效果。这个实现不仅提供了流畅的视觉体验,还具备了手势识别和物理感知的交互反馈。
这种实现方式可以应用于各种需要优雅页面转场的场景,为用户提供更加流畅和直观的交互体验。无论是商品、卡片详情页,图片预览还是其他全屏展示场景,都能很好地提升应用的用户体验。