// tab_swipe.dart — Tab 左右滑动切换的手势仲裁组件 // // 约定(design/CLAUDE.md §5):横向位移 >60px 且明显大于纵向时切换 Tab; // 子页内的纵向滚动区域不响应该手势。 // // 仲裁机制:本组件只接 HorizontalDrag。纵向滚动由内部 ListView 的 // VerticalDragGestureRecognizer 在手势竞技场中胜出,本组件的横向识别器 // 自然落败——因此「滚动列表/纵向拖拽」永远不会误触发 Tab 切换,无需手写 // dx/dy 比例判断。横向为主的拖拽才会进入本组件并按阈值判定方向。 import 'package:flutter/material.dart'; /// 方向感知:1 = 向后(进入右侧 Tab),-1 = 向前(进入左侧 Tab)。 typedef SwipeCallback = void Function(); class HorizontalSwipeArea extends StatefulWidget { const HorizontalSwipeArea({ super.key, required this.child, this.onSwipeLeft, this.onSwipeRight, this.threshold = 60, }); /// 包裹的内容(通常是当前 Tab 页)。 final Widget child; /// 向左滑(手指从右往左)→ 切到下一个 Tab。 final VoidCallback? onSwipeLeft; /// 向右滑(手指从左往右)→ 切到上一个 Tab。 final VoidCallback? onSwipeRight; /// 触发阈值(逻辑像素)。 final double threshold; @override State createState() => _HorizontalSwipeAreaState(); } class _HorizontalSwipeAreaState extends State { double _dx = 0; @override Widget build(BuildContext context) { return GestureDetector( // 仅接横向拖拽:纵向滚动交给内部可滚动组件(竞技场仲裁)。 onHorizontalDragStart: (_) => _dx = 0, onHorizontalDragUpdate: (d) => _dx += d.delta.dx, onHorizontalDragEnd: (details) { final v = details.primaryVelocity ?? 0; // 位移过阈值,或带明显速度的快划。 final left = _dx <= -widget.threshold || v < -300; final right = _dx >= widget.threshold || v > 300; if (left) { widget.onSwipeLeft?.call(); } else if (right) { widget.onSwipeRight?.call(); } _dx = 0; }, child: widget.child, ); } } /// 方向感知的页面切换动画:200ms 轻移淡入(对齐 React pg-in-l / pg-in-r)。 class DirectionalTabSwitcher extends StatelessWidget { const DirectionalTabSwitcher({super.key, required this.index, required this.direction, required this.child}); /// 当前 Tab 索引(作为切换 key)。 final int index; /// 进入方向:1 从右进(下一个),-1 从左进(上一个),0 无动画。 final int direction; final Widget child; @override Widget build(BuildContext context) { return AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: PangolinMotionEaseOut.curve, transitionBuilder: (child, animation) { // 仅对「进入」的新页做方向位移 + 淡入;旧页直接淡出,避免回弹。 final isIncoming = child.key == ValueKey(index); if (direction == 0 || !isIncoming) { return FadeTransition(opacity: animation, child: child); } final begin = Offset(direction > 0 ? 0.06 : -0.06, 0); return FadeTransition( opacity: animation, child: SlideTransition( position: Tween(begin: begin, end: Offset.zero).animate(animation), child: child, ), ); }, layoutBuilder: (currentChild, previousChildren) => Stack( alignment: Alignment.topCenter, children: [...previousChildren, if (currentChild != null) currentChild], ), child: KeyedSubtree(key: ValueKey(index), child: child), ); } } /// 暴露 easeOut 曲线(避免在本文件 import 主题造成循环)。 class PangolinMotionEaseOut { PangolinMotionEaseOut._(); static const Curve curve = Cubic(0.22, 1, 0.36, 1); }