处理和响应触摸效果
我们可以用GestureDetector实现这个效果
GestureDetector(
///手势触摸移动开始,这里我们可以记录开始的触摸点,用来判断移动比例和动画的初始点
onHorizontalDragStart: onStart,
///手势触摸移动中,这里生成tab的切换效果,具体效果可以用户自定义,效果代码都在delegate类里.
onHorizontalDragUpdate: onUpdate,
///手势触摸结束,这里判断是切换到下一张卡片还是滑动失败,回滚当前tab
onHorizontalDragEnd: onEnd,
child: child,
);
触摸开始
///记录触摸初始点
onStart(DragStartDetails details) {
dragStart = details.globalPosition;
...
}
触摸移动中
onUpdate(DragUpdateDetails details) {
if (dragStart != null) {
///滑动方向,向左或向右
SlideDirection slideDirection;
///滑动进度.[0, 1]
double slidePercent = 0.0;
///当前触摸的点
final newPosition = details.globalPosition;
///拖动距离,如果大于零是向右拖动,如果小于零是向左拖动.
///当前点的x轴位置减去触摸起始点的x轴位置
final dx = newPosition.dx - dragStart.dx;
slidePercent = (dx / FULL_TRANSITION_PX).abs().clamp(0.0, 1.0).toDouble();
if (dx > 0) {
slideDirection = SlideDirection.leftToRight;
} else if (dx < 0) {
slideDirection = SlideDirection.rightToLeft;
} else {
slideDirection = SlideDirection.none;
slidePercent = 0;
}
...
}
}
动画处理
我们在触摸手势结束后开始动画处理,动画分为两个,一个是滑动成功的动画切换到下一个tab,一个是滑动失败(比如滑动距离很小,不需要跳转到下一个页面).这里的value是触摸手势的滑动比例和Animation的value,它们两个的值是相同的,这样可以有连贯的动画效果.
onAnimatedStart({SlideUpdate slideUpdate}) {
Duration duration;
///判断是否成功, 滑动的值 是否大于我们设置的滑动成功的比例,我们这里设置的是0.5.
_isSlideSuccess = value >= slideSuccessProportion;
///成功
if (_isSlideSuccess) {
final slideRemaining = 1.0 - value;
///计算tab切换的时间
duration = Duration(
milliseconds: (slideRemaining / PERCENT_PER_MILLISECOND).round());
_animationController.duration = duration;
///动画向前运行到1,动画结束后切换当前tab为下一页tab
_animationController.forward(from: value).whenComplete(() =>
animationCompleted());
} else {
///失败,回退当当前tab
duration =
Duration(milliseconds: (value / PERCENT_PER_MILLISECOND).round());
_animationController.duration = duration;
///将动画值回退到0.
_animationController.reverse(from: value);
}
}
效果自定义
这里用了AnyTabDelegate抽象类,我们可以继承这个抽象类来实现任意效果.这样做最大的好处就是分离ui和逻辑的处理.
abstract class AnyTabDelegate {
///tab列表
List tabs;
AnyTabDelegate({@required this.tabs});
int get length => tabs.length;
///逻辑处理后调用的build
Widget build(
BuildContext context,
///当前tab页
int activeIndex,
///下一页
int nextPageIndex,
///动画值,它的value就是手势触摸的值和动画执行的值.
Animation animation,
///触摸的初始点,用于动画的初始点
Offset startingOffset,
);
}
这里我们来看一下CircularAnyTabDelegate的实现,这里我们用了ClipOval来剪裁下一页要显示的tab,如果传入的percentage是0则完全不显示,是1这完全显示.
class CircularAnyTabDelegate extends AnyTabDelegate {
CircularAnyTabDelegate({@required List tabs})
: assert(tabs != null && tabs.length > 0),
super(tabs: tabs);
@override
Widget build(BuildContext context, int activeIndex, int nextPageIndex,
Animation animation, Offset startingOffset) {
return Stack(
children: [
tabs[activeIndex],
ClipOval(
clipper: CircularClipper(
percentage: animation.value,
offset: startingOffset,
),
child: tabs[nextPageIndex],
)
],
);
}
}
再往下看一下CircularClipper的代码.
class CircularClipper extends CustomClipper {
///百分比, 0-> 1,1 => 全部显示
final double percentage;
///初始点
final Offset offset;
const CircularClipper({this.percentage = 0, this.offset = Offset.zero});
@override
Rect getClip(Size size) {
///计算触摸初始点到边缘四个角的最大距离,也就是我们剪裁圆的半径
double maxValue = maxLength(size, offset) * percentage;
return Rect.fromLTRB(-maxValue + offset.dx, -maxValue + offset.dy, maxValue + offset.dx, maxValue + offset.dy);
}
@override
bool shouldReclip(CircularClipper oldClipper) {
return percentage != oldClipper.percentage || offset != oldClipper.offset;
}
/// |
/// 1 | 2
/// ---------
/// 3 | 4
/// |
/// 计算矩形内点到边缘的最大距离,这里我们把矩形分成四块,
/// 点在那一块,最大的距离就是这个点到对角矩形最远那个点的距离
double maxLength(Size size, Offset offset) {
double centerX = size.width / 2;
double centerY = size.height / 2;
if (offset.dx < centerX && offset.dy < centerY) {
///1
return getEdge(size.width - offset.dx, size.height - offset.dy);
} else if (offset.dx > centerX && offset.dy < centerY) {
///2
return getEdge(offset.dx, size.height - offset.dy);
} else if (offset.dx < centerX && offset.dy > centerY) {
///3
return getEdge(size.width - offset.dx, offset.dy);
} else {
///4
return getEdge(offset.dx, offset.dy);
}
}
double getEdge(double width, double height) {
return sqrt(pow(width, 2) + pow(height, 2));
}
}