Flutter Animations:互动式翻页小工具的教程

301 阅读8分钟

这是我的Flutter动画教程的第二部分,关于如何建立一个互动的翻页小部件。

第一部分中,我们已经创建了一个PageFlipBuilder widget,使用AnimationControllerAnimatedBuilder ,和一个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. 当我们从左边拖动到右边(和后面)时,交互式拖动是有效的。但如果我们从首页开始,并试图向左拖动,则不会发生任何事情。
  2. 如果我们在中途释放指针,页面仍然是部分旋转的,没有完成翻转动画。

用交互式拖动翻转页面

让我们一次解决一个问题。

1.重新审视AnimationController和AnimationBuilder的代码

我们的问题是,我们的AnimationController'的值默认范围从0.0到1.0。

当我们向左拖动时,details.primaryDelta 会有一个负值,但控制器的值不会低于0.0

为了说明这一点,我们可以明确设置lowerBoundupperBoundAnimationController

_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,这相当于-pipi 之间的旋转角。

理想情况下,当我们以编程方式翻转页面时,我们希望动画值从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 的值选择backBuilderfrontBuilder

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 小组件
  • 使用AnimatedBuilderTransform 小组件来实现三维旋转
  • 使用一个GestureDetector ,并使用拖动手势工作
  • 在实践中使用AnimationController'slowerBoundupperBound
  • 将本地小部件的状态与AnimationController 的值同步。
  • 使用fling() 方法来完成一个动画

你可以使用PageFlipBuilder 来增加一丝魔力,使你的应用程序更有乐趣。

在pub.dev上发布了PageFlipBuilder,所以你可以很容易地安装它并使用它来翻转任何大小的页面、卡片或部件。

如果你想了解更多关于动画的信息,Flutter文档中有一个非常好的介绍

编码愉快!