这是我的Flutter动画教程的第二部分,关于如何建立一个互动的翻页小部件。
在第一部分中,我们已经创建了一个PageFlipBuilder widget,使用AnimationController ,AnimatedBuilder ,和一个Transform widget来应用一个3D旋转。
带有倾斜效果的翻页
但我们还没有完成,因为我们仍然需要使我们的PageFlipBuilder 。
这将是一个艰巨的挑战,所以让我们发挥我们的编码能力,潜入其中。💪
如果你想一起编码,你可以从这里获取源代码。
使用GestureDetector进行交互式翻页
这里的主要想法是使用一个GestureDetector 来检测屏幕上的水平拖动手势,并根据指针的delta来更新AnimationController's的值。
GestureDetector、AnimationController和AnimatedBuilder之间的相互作用
为了使其正常工作,我们可以添加一个GestureDetector ,作为我们的AnimatedPageFlipBuilder 的父节点。
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: _handleDragUpdate,
child: AnimatedPageFlipBuilder(
animation: _controller,
frontBuilder: widget.frontBuilder,
backBuilder: widget.backBuilder,
showFrontSide: _showFrontSide,
),
);
}
然后,我们可以实现_handleDragUpdate() 方法。
void _handleDragUpdate(DragUpdateDetails details) {
final screenWidth = MediaQuery.of(context).size.width;
_controller.value += details.primaryDelta! / screenWidth;
}
有几件事需要注意。
details.primaryDelta告诉我们指针在主轴上移动了多少。它是一个可置空的变量,但我们可以安全地使用 ,因为对于一维拖动手势来说,它总是!非空的,如 。onHorizontalDragUpdate- 我们的控制器的值可以在0.0到1.0之间。我们想把它映射到屏幕宽度上,这样从一边拖动到另一边就正好完成一次翻转。因此,我们用
primaryDelta除以我们从MediaQuery得到的屏幕宽度。
如果我们现在运行这个应用程序,我们可以注意到两件事。
- 当我们从左边拖动到右边(和后面)时,交互式拖动是有效的。但如果我们从首页开始,并试图向左拖动,则不会发生任何事情。
- 如果我们在中途释放指针,页面仍然是部分旋转的,没有完成翻转动画。
用交互式拖动翻转页面
让我们一次解决一个问题。
1.重新审视AnimationController和AnimationBuilder的代码
我们的问题是,我们的AnimationController'的值默认范围从0.0到1.0。
当我们向左拖动时,details.primaryDelta 会有一个负值,但控制器的值不会低于0.0。
为了说明这一点,我们可以明确设置lowerBound 和upperBound 的AnimationController 。
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
// lowerBound of -1.0 is needed for the back flip
lowerBound: -1.0,
// upperBound of 1.0 is needed for the front flip
upperBound: 1.0,
);
如果我们现在热重启,欢迎我们的是一个非常拉长的页面。
破碎的矩阵变换
这到底是怎么回事?
嗯,我们有一个新的问题,因为我们的矩阵变换代码只被设计为在0.0到1.0的范围内工作。但是对于小于-0.5的值,事情就变得不正常了。😅
现在我们需要处理-1.0到1.0之间的所有动画值。
所以让我们来解决这个问题。为了使事情更容易理解,让我们在我们的AnimatedPageFlipBuilder 类中创建单独的方法来计算倾斜和旋转角度的值。
bool get _isAnimationFirstHalf => animation.value.abs() < 0.5;
double _getTilt() {
var tilt = (animation.value - 0.5).abs() - 0.5;
if (animation.value < -0.5) {
tilt = 1.0 + animation.value;
}
return tilt * (_isAnimationFirstHalf ? -0.003 : 0.003);
}
double _rotationAngle() {
final rotationValue = animation.value * pi;
if (animation.value > 0.5) {
return pi - rotationValue; // input from 0.5 to 1.0
} else if (animation.value > -0.5) {
return rotationValue; // input from -0.5 to 0.5
} else {
return -pi - rotationValue; // input from -1.0 to -0.5
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final child = _isAnimationFirstHalf
? frontBuilder(context)
: backBuilder(context);
return Transform(
transform: Matrix4.rotationY(_rotationAngle())
..setEntry(3, 0, _getTilt()),
child: child,
alignment: Alignment.center,
);
},
);
}
_rotationAngle() 和_getTilt() 方法现在对-1.0和1.0之间的动画值是正确的。下面是一些显示它们如何工作的2D图。
范围为(-1.0, 1.0)的旋转角度图
范围为(-1.0, 1.0)的倾斜图。
如果我们现在热重新加载并尝试一下,我们注意到交互式拖动在两个方向上都能工作。
但是我们仍然有一个小问题:当我们按下翻转按钮时**,页面翻转了两次**,而不是一次。
现在页面翻转了两次
这是因为AnimationController 的值现在从-1.0到1.0,这相当于-pi 和pi 之间的旋转角。
理想情况下,当我们以编程方式翻转页面时,我们希望动画值从0.0开始,然后向前走到1.0或向后走到-1.0。这将确保我们通过pi 而不是2 * pi 来旋转页面。
为了达到这个目的,我们需要做一些改变。
首先,让我们确保AnimationController'的值被初始化为0.0,在initState() 。
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: widget.nonInteractiveAnimationDuration,
// lowerBound of -1.0 is needed for the back flip
lowerBound: -1.0,
// upperBound of 1.0 is needed for the front flip
upperBound: 1.0,
);
// Start from 0.0. This needs to be explicit because the lowerBound is now -1.0
_controller.value = 0.0;
_controller.addStatusListener(_updateStatus);
super.initState();
}
然后,让我们更新_updateStatus ,在动画完成后将其值重置为0.0。
void _updateStatus(AnimationStatus status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
// The controller always completes a forward animation with value 1.0
// and a reverse animation with a value of -1.0.
// By resetting the value to 0.0 and toggling the state
// we are preparing the controller for the next animation
// while preserving the widget appearance on screen.
_controller.value = 0.0;
setState(() => _showFrontSide = !_showFrontSide);
}
}
这样可以确保控制器为下一个动画做好准备。但是它有一个副作用,导致动画完成后页面从前面 "切换 "到后面。
可见页面在动画结束时 "切换"。
所以最后一步是在AnimatedBuilder 代码中使用showFrontSide 变量来选择正确的孩子。
final child = _isAnimationFirstHalf ^ showFrontSide
? backBuilder(context)
: frontBuilder(context);
这里我们使用XOR运算符(^),根据_isAnimationFirstHalf ^ showFrontSide 的值选择backBuilder 或frontBuilder 。
XOR也被称为独占或。这个维基百科页面解释了它的工作原理。
如果我们现在热重启,我们可以看到程序化的和交互式的翻页都在按计划工作。
现在翻页工作正常了
我们快完成了,我们只需要解决最后一个问题。
如果我们中途释放指针,页面仍然是部分旋转的,不能完成翻页动画。
准备好做最后的冲刺了吗?
让我们抛出AnimationController!
如果我们中途释放指针,如何告诉我们的AnimationController 来完成动画呢?
诀窍是使用fling() 方法。
_controller.fling(velocity: velocity);
通过调用这个方法,如果我们传递一个正的 velocity ,动画将向前完成,如果我们传递一个负的 velocity ,动画将反向完成。
所以让我们从一个简化的实现开始,来完成这个任务。
首先,我们可以给我们的GestureDetector 添加一个新的参数onHorizontalDragEnd 。
return GestureDetector(
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
child: AnimatedPageFlipBuilder(
animation: _controller,
frontBuilder: widget.frontBuilder,
backBuilder: widget.backBuilder,
showFrontSide: _showFrontSide,
),
);
然后我们可以像这样定义_handleDragEnd() 方法。
void _handleDragEnd(DragEndDetails details) {
// If the controller is currently animating or has already completed, do nothing
if (_controller.isAnimating ||
_controller.status == AnimationStatus.completed ||
_controller.status == AnimationStatus.dismissed) return;
// calculate the currentVelocity based on the drag velocity and screen width
final screenWidth = MediaQuery.of(context).size.width;
final currentVelocity = details.velocity.pixelsPerSecond.dx / screenWidth;
// if value and velocity are 0.0, the gesture was a tap so we return early
if (_controller.value == 0.0 && currentVelocity == 0.0) {
return;
}
const flingVelocity = 2.0;
if (_controller.value > 0.5) {
_controller.fling(velocity: flingVelocity);
} else if (_controller.value < -0.5) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > 0.0) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > -0.5) {
_controller.fling(velocity: flingVelocity);
}
}
该方法的第一部分检查一些条件,如果没有什么可做的就提前返回。
第二部分是最有趣的。这里的主要思想是将整个动画范围划分为四个子范围,并将控制器甩到所需的动画值。
- 0.5到1.0→向前甩动到1.0
- 0.0到0.5→向后甩动到0.0
- -0.5到0.0→向前甩动到0.0
- -1.0到-0.5→向后翻转到-1.0
不幸的是,不可能甩到0.0,因为控制器会过冲,一直到-1.0或1.0。
不要担心,因为我们可以通过同时改变控制器的值和_showFrontSide 变量来 "黑 "掉它。
if (_controller.value > 0.5) {
_controller.fling(velocity: flingVelocity);
} else if (_controller.value < -0.5) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > 0.0) {
// controller can't fling to 0.0 because the lowerBound is -1.0
// so we decrement the value by 1.0 and toggle the state to get the same effect
_controller.value -= 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > -0.5) {
// controller can't fling to 0.0 because the upperBound is 1.0
// so we increment the value by 1.0 and toggle the state to get the same effect
_controller.value += 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: flingVelocity);
}
如果我们现在运行这个应用程序,我们可以看到页面翻转到最近的非小数动画值。
交互式翻页:当指针被释放时向后翻转
这很有效,但如果我们能在flingVelocity 大于velocityThreshold 的情况下将页面 "翻转 "到另一边,那就更好了。
所以这里是 "翻转 "代码的最终版本。
if (_controller.value > 0.5 ||
_controller.value > 0.0 && currentVelocity > flingVelocity) {
_controller.fling(velocity: flingVelocity);
} else if (_controller.value < -0.5 ||
_controller.value < 0.0 && currentVelocity < -flingVelocity) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > 0.0 ||
_controller.value > 0.5 && currentVelocity < -flingVelocity) {
// controller can't fling to 0.0 because the lowerBound is -1.0
// so we decrement the value by 1.0 and toggle the state to get the same effect
_controller.value -= 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: -currentVelocity);
} else if (_controller.value > -0.5 ||
_controller.value < -0.5 && currentVelocity > flingVelocity) {
// controller can't fling to 0.0 because the upperBound is 1.0
// so we increment the value by 1.0 and toggle the state to get the same effect
_controller.value += 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: flingVelocity);
}
有了这个,我们就可以最后一次热加载应用程序,并获得一些乐趣。
考虑到速度因素的交互式翻页
如果你已经走到了这一步,那么恭喜你!你现在是一个Flutter动画制作者了。您现在是Flutter动画专家了🚀
是的,让我们来翻转它!
结语
我们现在已经完成了我们的交互式翻页小部件。
在这一过程中,我们学会了如何。
- 将一个
AnimationController作为输入传给一个AnimatedBuilder小组件 - 使用
AnimatedBuilder和Transform小组件来实现三维旋转 - 使用一个
GestureDetector,并使用拖动手势工作 - 在实践中使用
AnimationController'slowerBound和upperBound - 将本地小部件的状态与
AnimationController的值同步。 - 使用
fling()方法来完成一个动画
你可以使用PageFlipBuilder 来增加一丝魔力,使你的应用程序更有乐趣。
我在pub.dev上发布了PageFlipBuilder,所以你可以很容易地安装它并使用它来翻转任何大小的页面、卡片或部件。
如果你想了解更多关于动画的信息,Flutter文档中有一个非常好的介绍。
编码愉快!