Flutter 小技巧之实现一个精美的动画相册效果

今天的小技巧主要是「抄袭」一个充满设计感的相册控件,如下图所示是 gskinner 开源应用 wonderous 里一个相片集的实现效果,可以看到相册支持上下左右滑动,并带有高亮展示的动画效果,而且相册整体布局可以超出屏幕滚动,因为是开源的 App, 我们只需要「照搬」就可以实现一摸一样的效果,那么如果要实现这样的效果,你第一反应是用什么基础控件?

Flutter 小技巧之实现一个精美的动画相册效果 - 图1

因为需要支持上下左右自由滑动,可能大家第一反应会是 Table ,还是嵌套两个 ListView ?但是从上面的效果体验上看,控件滑动的过程并不是一个正常 Scroll 控件的线性效果,因为它并不是「跟随手指滑动」的状态。

既然是开源代码,我们通过源码可以发现它是用了 GridView 来实现,这也是这个效果里最有趣的点,一个 GridView 如何变成一个带有动画的 Photo Gallery 。

所以本篇的核心是分析 wonderous 里的 Photo Gallery 是如何实现的,并剥离出简单代码

Photo Gallery

要实现上述的 Photo Gallery 效果,主要需要解决三个方面核心的要点:

  • 1、GridView 所在区域的上下左右要超出屏幕
  • 2、GridView 如何实现上下左右自由切换
  • 3、高亮展示选中 Item 的动画效果

首先是第一点的方案肯定是 OverflowBox ,因它支持解放 Child 的布局约束,允许 Child 溢出父布局,因为前面的 Photo Gallery 在水平方向设定是 5 个 Item,而 GridView 是默认是上下滑动,所以可以简单的设定一个 maxWidthmaxHeight 来作为 Child 超出屏幕后大小。

  1. OverflowBox(
  2. maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1),
  3. maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1),
  4. alignment: Alignment.center,
  5. child:

可以看到「超出屏幕」这个需求还是比较简单,接下里就是 「GridView 如何实现上下左右自由切换」这个问题。

小技巧 1 :在合适场合使用 OverflowBox 可以溢出屏幕

默认情况下 GridView 肯定只支持一个方向滑动,所以干脆我们禁止 GridView 的滑动逻辑,让 GridView 只管布局,后面滑动逻辑通过自定义的 GestureDetector 来实现。

  1. GridView.count(
  2. physics: NeverScrollableScrollPhysics(),

如下代码所示,我们通过封装 GestureDetector 来实现手势识别,这里核心的要点就是 _maybeTriggerSwipe 的实现,它的作用就是得到手势滑动的方向结果,对于滑动具体大于 threshold 的参数,通过「采样」将数据变成 -1、 0 、 1 这样的结果来代表方向:

  • Offset(1.0, 0.0) 是手指右滑
  • Offset(-1.0, 0.0) 是手指左滑
  • Offset(0.0, 1.0) 是手指下滑
  • Offset(0.0, -1.0) 是手指上滑
  1. class _EightWaySwipeDetectorState extends State<EightWaySwipeDetector> {
  2. Offset _startPos = Offset.zero;
  3. Offset _endPos = Offset.zero;
  4. bool _isSwiping = false;
  5. void _resetSwipe() {
  6. _startPos = _endPos = Offset.zero;
  7. _isSwiping = false;
  8. }
  9. ///这里主要是返回一个 -1 ~ 1 之间的数值,具体用于判断方向
  10. /// Offset(1.0, 0.0) 是手指右滑
  11. /// Offset(-1.0, 0.0) 是手指左滑
  12. /// Offset(0.0, 1.0) 是手指下滑
  13. /// Offset(0.0, -1.0) 是手指上滑
  14. void _maybeTriggerSwipe() {
  15. // Exit early if we're not currently swiping
  16. if (_isSwiping == false) return;
  17. /// 开始和结束位置计算出移动距离
  18. // Get the distance of the swipe
  19. Offset moveDelta = _endPos - _startPos;
  20. final distance = moveDelta.distance;
  21. /// 对比偏移量大小是否超过了 threshold ,不能小于 1
  22. // Trigger swipe if threshold has been exceeded, if threshold is < 1, use 1 as a minimum value.
  23. if (distance >= max(widget.threshold, 1)) {
  24. // Normalize the dx/dy values between -1 and 1
  25. moveDelta /= distance;
  26. // Round the dx/dy values to snap them to -1, 0 or 1, creating an 8-way directional vector.
  27. Offset dir = Offset(
  28. moveDelta.dx.roundToDouble(),
  29. moveDelta.dy.roundToDouble(),
  30. );
  31. widget.onSwipe?.call(dir);
  32. _resetSwipe();
  33. }
  34. }
  35. void _handleSwipeStart(d) {
  36. _isSwiping = true;
  37. _startPos = _endPos = d.localPosition;
  38. }
  39. void _handleSwipeUpdate(d) {
  40. _endPos = d.localPosition;
  41. _maybeTriggerSwipe();
  42. }
  43. void _handleSwipeEnd(d) {
  44. _maybeTriggerSwipe();
  45. _resetSwipe();
  46. }
  47. @override
  48. Widget build(BuildContext context) {
  49. return GestureDetector(
  50. behavior: HitTestBehavior.translucent,
  51. onPanStart: _handleSwipeStart,
  52. onPanUpdate: _handleSwipeUpdate,
  53. onPanCancel: _resetSwipe,
  54. onPanEnd: _handleSwipeEnd,
  55. child: widget.child);
  56. }
  57. }

小技巧 2:Offset.distance 可以用来作为判断偏移量的大小

知道了手势方向之后,我们就可以处理 GridView 应该如何滑动,这里我们需要先知道当然应该展示哪个 index 。

默认情况下我们需要展示的是最中间的 Item ,例如有 25 个 Item 的时候, index 应该在第 13 ,然后我们再根据方向来调整下一个 index 是哪个:

  • dy > 0 ,就是手指下滑,也就是页面要往上,那么 index 就需要 -1,反过来就是 + 1
  • dx > 0 ,就是手指右滑,也就是页面要往左,那么 index 就需要 -1,反过来就是 + 1
  1. // Index starts in the middle of the grid (eg, 25 items, index will start at 13)
  2. int _index = ((_gridSize * _gridSize) / 2).round();
  3. /// Converts a swipe direction into a new index
  4. void _handleSwipe(Offset dir) {
  5. // Calculate new index, y swipes move by an entire row, x swipes move one index at a time
  6. int newIndex = _index;
  7. /// Offset(1.0, 0.0) 是手指右滑
  8. /// Offset(-1.0, 0.0) 是手指左滑
  9. /// Offset(0.0, 1.0) 是手指下滑
  10. /// Offset(0.0, -1.0) 是手指上滑
  11. /// dy > 0 ,就是手指下滑,也就是页面要往上,那么 index 就需要 -1,反过来就是 + 1
  12. if (dir.dy != 0) newIndex += _gridSize * (dir.dy > 0 ? -1 : 1);
  13. /// dx > 0 ,就是手指右滑,也就是页面要往左,那么 index 就需要 -1,反过来就是 + 1
  14. if (dir.dx != 0) newIndex += (dir.dx > 0 ? -1 : 1);
  15. ///这里判断下 index 是不是超出位置
  16. // After calculating new index, exit early if we don't like it...
  17. if (newIndex < 0 || newIndex > _imgCount - 1)
  18. return; // keep the index in range
  19. if (dir.dx < 0 && newIndex % _gridSize == 0)
  20. return; // prevent right-swipe when at right side
  21. if (dir.dx > 0 && newIndex % _gridSize == _gridSize - 1)
  22. return; // prevent left-swipe when at left side
  23. /// 响应
  24. _lastSwipeDir = dir;
  25. HapticFeedback.lightImpact();
  26. _setIndex(newIndex);
  27. }
  28. void _setIndex(int value, {bool skipAnimation = false}) {
  29. if (value < 0 || value >= _imgCount) return;
  30. setState(() => _index = value);
  31. }

通过手势方向,我们就可以得到下一个需要展示的 Item 的 index 是什么,然后就可以使用 Transform.translate 来移动 GridView

是的,在这个 Photo Gallery 里的滑动效果是通过 Transform.translate 实现,核心之一也就是根据方向计算其应该偏移的 Offset 位置

  • 首先根据水平方向的数量 / 2 得到一个 halfCount
  • 计算出一个 Item 加上 Padding 大小的 paddedImageSize
  • 计算出默认中心位置的 top-left 的 originOffset
  • 计算出要移动的 index 所在的行和列位置 indexedOffset
  • 最后两者相减(因为 indexedOffset 里是负数),得到一个相对的偏移 Offset
  1. /// Determine the required offset to show the current selected index.
  2. /// index=0 is top-left, and the index=max is bottom-right.
  3. Offset _calculateCurrentOffset(double padding, Size size) {
  4. /// 获取水平方向一半的大小,默认也就是 2.0,因为 floorToDouble
  5. double halfCount = (_gridSize / 2).floorToDouble();
  6. /// Item 大小加上 Padding,也就是每个 Item 的实际大小
  7. Size paddedImageSize = Size(size.width + padding, size.height + padding);
  8. /// 计算出开始位置的 top-left
  9. // Get the starting offset that would show the top-left image (index 0)
  10. final originOffset = Offset(
  11. halfCount * paddedImageSize.width, halfCount * paddedImageSize.height);
  12. /// 得到要移动的 index 所在的行和列位置
  13. // Add the offset for the row/col
  14. int col = _index % _gridSize;
  15. int row = (_index / _gridSize).floor();
  16. /// 负数计算出要移动的 index 的 top-left 位置,比如 index 比较小,那么这个 indexedOffset 就比中心点小,相减之后 Offset 就会是正数
  17. /// 是不是有点懵逼?为什么正数 translate 会往 index 小的 方向移动??
  18. /// 因为你代入的不对,我们 translate 移动的是整个 GridView
  19. /// 正数是向左向下移动,自然就把左边或者上面的 Item 显示出来
  20. final indexedOffset =
  21. Offset(-paddedImageSize.width * col, -paddedImageSize.height * row);
  22. return originOffset + indexedOffset;
  23. }

具体点如下图所示,比如在 5 x 5 的 GridView 下:

  • 通过 halfCountpaddedImageSize 计算会得到黑色虚线的位置
  • 红色是要展示的 index 位置,也就是通过 colrow 计算出来的 indexedOffset 就是红色框的左上角,在上面代码里用过的是负数
  • originOffset + indexedOffset ,其实就是得到两者之差的 currentOffset,比如这时候得到是一个 dx 为正数的 Offset ,整个 GridView 要向左移动一个 currentOffset ,自然就把红色框放到中间显示。

Flutter 小技巧之实现一个精美的动画相册效果 - 图2

更形象的可以看这个动画,核心就是整个 GridView 在发生了偏移,从把需要展示的 Item 移动到中心的位置,利用 Transform.translate 来实现类似滑动的效果,当然实现里还会用到 TweenAnimationBuilder 来实现动画过程,

Flutter 小技巧之实现一个精美的动画相册效果 - 图3

  1. TweenAnimationBuilder<Offset>(
  2. tween: Tween(begin: gridOffset, end: gridOffset),
  3. duration: offsetTweenDuration,
  4. curve: Curves.easeOut,
  5. builder: (_, value, child) =>
  6. Transform.translate(offset: value, child: child),
  7. child: GridView.count(
  8. physics: NeverScrollableScrollPhysics(),

解决完移动,最后就是实现蒙层和高亮动画效果,这个的核心主要是通过 flutter_animate 包和 ClipPath 实现,如下代码所示:

  • 使用 Animate 并在上面添加一个具有透明度的黑色 Container
  • 利用 CustomEffect 添加自定义动画
  • 在动画里利用 ClipPath ,并通过自定义 CustomClipper 结合动画 value 实现 PathOperation.difference 的「挖空」效果

动画效果就是根据 Animate 的 value 得到的 cutoutSize ,默认是从 1 - 0.25 * x 开始,这里的 x 是滑动方向,最终表现就是从 0.75 到 1 的过程,所以动画会根据方向有一个从 0.75 到 1 的展开效果。

  1. @override
  2. Widget build(BuildContext context) {
  3. return Stack(
  4. children: [
  5. child,
  6. // 用 ClipPath 做一个动画抠图
  7. Animate(
  8. effects: [
  9. CustomEffect(
  10. builder: _buildAnimatedCutout,
  11. curve: Curves.easeOut,
  12. duration: duration)
  13. ],
  14. key: animationKey,
  15. onComplete: (c) => c.reverse(),
  16. // 用一个黑色的蒙层,这里的 child 会变成 effects 里 builder 里的 child
  17. // 也就是黑色 Container 会在 _buildAnimatedCutout 作为 ClipPath 的 child
  18. child: IgnorePointer(
  19. child: Container(color: Colors.black.withOpacity(opacity))),
  20. ),
  21. ],
  22. );
  23. }
  24. /// Scales from 1 --> (1 - scaleAmt) --> 1
  25. Widget _buildAnimatedCutout(BuildContext context, double anim, Widget child) {
  26. // controls how much the center cutout will shrink when changing images
  27. const scaleAmt = .25;
  28. final size = Size(
  29. cutoutSize.width * (1 - scaleAmt * anim * swipeDir.dx.abs()),
  30. cutoutSize.height * (1 - scaleAmt * anim * swipeDir.dy.abs()),
  31. );
  32. return ClipPath(clipper: _CutoutClipper(size), child: child);
  33. }
  34. class _CutoutClipper extends CustomClipper<Path> {
  35. _CutoutClipper(this.cutoutSize);
  36. final Size cutoutSize;
  37. @override
  38. Path getClip(Size size) {
  39. double padX = (size.width - cutoutSize.width) / 2;
  40. double padY = (size.height - cutoutSize.height) / 2;
  41. return Path.combine(
  42. PathOperation.difference,
  43. Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
  44. Path()
  45. ..addRRect(
  46. RRect.fromLTRBR(
  47. padX,
  48. padY,
  49. size.width - padX,
  50. size.height - padY,
  51. Radius.circular(6),
  52. ),
  53. )
  54. ..close(),
  55. );
  56. }
  57. @override
  58. bool shouldReclip(_CutoutClipper oldClipper) =>
  59. oldClipper.cutoutSize != cutoutSize;
  60. }

从这里可以看到,其实高亮的效果就是在黑色的蒙层上,利用 PathOperation.difference 「挖」出来一个空白的 Path 。

小技巧 3 : PathOperation.difference 可以用在需要「镂空」 的场景上

更直观的可以参考一下例子,就是对两个路径进行 difference 操作,,利用 Rect2 把 Rect1 中间给消除掉,得到一个中间 「镂空」的绘制 Path。

  1. class ShowPathDifference extends StatelessWidget {
  2. @override
  3. Widget build(BuildContext context) {
  4. return Scaffold(
  5. appBar: AppBar(
  6. title: Text('ShowPathDifference'),
  7. ),
  8. body: Stack(
  9. alignment: Alignment.center,
  10. children: [
  11. Center(
  12. child: Container(
  13. width: 300,
  14. height: 300,
  15. decoration: BoxDecoration(
  16. image: DecorationImage(
  17. fit: BoxFit.cover,
  18. image: AssetImage("static/gsy_cat.png"),
  19. ),
  20. ),
  21. ),
  22. ),
  23. Center(
  24. child: CustomPaint(
  25. painter: ShowPathDifferencePainter(),
  26. ),
  27. ),
  28. ],
  29. ),
  30. );
  31. }
  32. }
  33. class ShowPathDifferencePainter extends CustomPainter {
  34. @override
  35. void paint(Canvas canvas, Size size) {
  36. final paint = Paint();
  37. paint.color = Colors.blue.withAlpha(160);
  38. canvas.drawPath(
  39. Path.combine(
  40. PathOperation.difference,
  41. Path()
  42. ..addRRect(
  43. RRect.fromLTRBR(-150, -150, 150, 150, Radius.circular(10))),
  44. Path()
  45. ..addOval(Rect.fromCircle(center: Offset(0, 0), radius: 100))
  46. ..close(),
  47. ),
  48. paint,
  49. );
  50. }
  51. @override
  52. bool shouldRepaint(CustomPainter oldDelegate) => false;
  53. }

Flutter 小技巧之实现一个精美的动画相册效果 - 图4

最终效果如下图所依,这里是把 wonderous 里关键部分代码剥离出来后的效果,因为 wonderous 并没有把这部分代码封装为 package ,所以我把这部分代码剥离出来放在了后面,感兴趣的可以自己运行试试效果。

Flutter 小技巧之实现一个精美的动画相册效果 - 图5

源码

  1. import 'dart:math';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_animate/flutter_animate.dart';
  5. /// 来自 https://github.com/gskinnerTeam/flutter-wonderous-app 上的一个 UI 效果
  6. class PhotoGalleryDemoPage extends StatefulWidget {
  7. const PhotoGalleryDemoPage({Key? key}) : super(key: key);
  8. @override
  9. State<PhotoGalleryDemoPage> createState() => _PhotoGalleryDemoPageState();
  10. }
  11. class _PhotoGalleryDemoPageState extends State<PhotoGalleryDemoPage> {
  12. @override
  13. Widget build(BuildContext context) {
  14. return PhotoGallery();
  15. }
  16. }
  17. class PhotoGallery extends StatefulWidget {
  18. const PhotoGallery({Key? key}) : super(key: key);
  19. @override
  20. State<PhotoGallery> createState() => _PhotoGalleryState();
  21. }
  22. class _PhotoGalleryState extends State<PhotoGallery> {
  23. static const int _gridSize = 5;
  24. late List<Color> colorList;
  25. // Index starts in the middle of the grid (eg, 25 items, index will start at 13)
  26. int _index = ((_gridSize * _gridSize) / 2).round();
  27. Offset _lastSwipeDir = Offset.zero;
  28. bool _skipNextOffsetTween = false;
  29. ///根据屏幕尺寸,决定 Padding 的大小,通过 scale 缩放
  30. _getPadding(Size size) {
  31. double scale = 1;
  32. final shortestSide = size.shortestSide;
  33. const tabletXl = 1000;
  34. const tabletLg = 800;
  35. const tabletSm = 600;
  36. const phoneLg = 400;
  37. if (shortestSide > tabletXl) {
  38. scale = 1.25;
  39. } else if (shortestSide > tabletLg) {
  40. scale = 1.15;
  41. } else if (shortestSide > tabletSm) {
  42. scale = 1;
  43. } else if (shortestSide > phoneLg) {
  44. scale = .9; // phone
  45. } else {
  46. scale = .85; // small phone
  47. }
  48. return 24 * scale;
  49. }
  50. int get _imgCount => pow(_gridSize, 2).round();
  51. Widget _buildImage(int index, Size imgSize) {
  52. /// Bind to collectibles.statesById because we might need to rebuild if a collectible is found.
  53. return ClipRRect(
  54. borderRadius: BorderRadius.circular(8),
  55. child: Container(
  56. width: imgSize.width,
  57. height: imgSize.height,
  58. color: colorList[index],
  59. ),
  60. );
  61. }
  62. /// Converts a swipe direction into a new index
  63. void _handleSwipe(Offset dir) {
  64. // Calculate new index, y swipes move by an entire row, x swipes move one index at a time
  65. int newIndex = _index;
  66. /// Offset(1.0, 0.0) 是手指右滑
  67. /// Offset(-1.0, 0.0) 是手指左滑
  68. /// Offset(0.0, 1.0) 是手指下滑
  69. /// Offset(0.0, -1.0) 是手指上滑
  70. /// dy > 0 ,就是手指下滑,也就是页面要往上,那么 index 就需要 -1,反过来就是 + 1
  71. if (dir.dy != 0) newIndex += _gridSize * (dir.dy > 0 ? -1 : 1);
  72. /// dx > 0 ,就是手指右滑,也就是页面要往左,那么 index 就需要 -1,反过来就是 + 1
  73. if (dir.dx != 0) newIndex += (dir.dx > 0 ? -1 : 1);
  74. ///这里判断下 index 是不是超出位置
  75. // After calculating new index, exit early if we don't like it...
  76. if (newIndex < 0 || newIndex > _imgCount - 1)
  77. return; // keep the index in range
  78. if (dir.dx < 0 && newIndex % _gridSize == 0)
  79. return; // prevent right-swipe when at right side
  80. if (dir.dx > 0 && newIndex % _gridSize == _gridSize - 1)
  81. return; // prevent left-swipe when at left side
  82. /// 响应
  83. _lastSwipeDir = dir;
  84. HapticFeedback.lightImpact();
  85. _setIndex(newIndex);
  86. }
  87. void _setIndex(int value, {bool skipAnimation = false}) {
  88. print("######## $value");
  89. if (value < 0 || value >= _imgCount) return;
  90. _skipNextOffsetTween = skipAnimation;
  91. setState(() => _index = value);
  92. }
  93. /// Determine the required offset to show the current selected index.
  94. /// index=0 is top-left, and the index=max is bottom-right.
  95. Offset _calculateCurrentOffset(double padding, Size size) {
  96. /// 获取水平方向一半的大小,默认也就是 2.0,因为 floorToDouble
  97. double halfCount = (_gridSize / 2).floorToDouble();
  98. /// Item 大小加上 Padding,也就是每个 Item 的实际大小
  99. Size paddedImageSize = Size(size.width + padding, size.height + padding);
  100. /// 计算出开始位置的 top-left
  101. // Get the starting offset that would show the top-left image (index 0)
  102. final originOffset = Offset(
  103. halfCount * paddedImageSize.width, halfCount * paddedImageSize.height);
  104. /// 得到要移动的 index 所在的行和列位置
  105. // Add the offset for the row/col
  106. int col = _index % _gridSize;
  107. int row = (_index / _gridSize).floor();
  108. /// 负数计算出要移动的 index 的 top-left 位置,比如 index 比较小,那么这个 indexedOffset 就比中心点小,相减之后 Offset 就会是正数
  109. /// 是不是有点懵逼?为什么正数 translate 会往 index 小的 方向移动??
  110. /// 因为你代入的不对,我们 translate 移动的是整个 GridView
  111. /// 正数是向左向下移动,自然就把左边或者上面的 Item 显示出来
  112. final indexedOffset =
  113. Offset(-paddedImageSize.width * col, -paddedImageSize.height * row);
  114. return originOffset + indexedOffset;
  115. }
  116. @override
  117. void initState() {
  118. colorList = List.generate(
  119. _imgCount,
  120. (index) => Color((Random().nextDouble() * 0xFFFFFF).toInt())
  121. .withOpacity(1));
  122. super.initState();
  123. }
  124. @override
  125. Widget build(BuildContext context) {
  126. var mq = MediaQuery.of(context);
  127. var width = mq.size.width;
  128. var height = mq.size.height;
  129. bool isLandscape = mq.orientation == Orientation.landscape;
  130. ///根据横竖屏状态决定 Item 大小
  131. Size imgSize = isLandscape
  132. ? Size(width * .5, height * .66)
  133. : Size(width * .66, height * .5);
  134. var padding = _getPadding(mq.size);
  135. final cutoutTweenDuration =
  136. _skipNextOffsetTween ? Duration.zero : Duration(milliseconds: 600) * .5;
  137. final offsetTweenDuration =
  138. _skipNextOffsetTween ? Duration.zero : Duration(milliseconds: 600) * .4;
  139. var gridOffset = _calculateCurrentOffset(padding, imgSize);
  140. gridOffset += Offset(0, -mq.padding.top / 2);
  141. //动画效果
  142. return _AnimatedCutoutOverlay(
  143. animationKey: ValueKey(_index),
  144. cutoutSize: imgSize,
  145. swipeDir: _lastSwipeDir,
  146. duration: cutoutTweenDuration,
  147. opacity: .7,
  148. child: SafeArea(
  149. bottom: false,
  150. // Place content in overflow box, to allow it to flow outside the parent
  151. child: OverflowBox(
  152. maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1),
  153. maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1),
  154. alignment: Alignment.center,
  155. // 手势获取方向上下左右
  156. child: EightWaySwipeDetector(
  157. onSwipe: _handleSwipe,
  158. threshold: 30,
  159. // A tween animation builder moves from image to image based on current offset
  160. child: TweenAnimationBuilder<Offset>(
  161. tween: Tween(begin: gridOffset, end: gridOffset),
  162. duration: offsetTweenDuration,
  163. curve: Curves.easeOut,
  164. builder: (_, value, child) =>
  165. Transform.translate(offset: value, child: child),
  166. child: GridView.count(
  167. physics: NeverScrollableScrollPhysics(),
  168. crossAxisCount: _gridSize,
  169. childAspectRatio: imgSize.aspectRatio,
  170. mainAxisSpacing: padding,
  171. crossAxisSpacing: padding,
  172. children:
  173. List.generate(_imgCount, (i) => _buildImage(i, imgSize)),
  174. )),
  175. ),
  176. ),
  177. ),
  178. );
  179. }
  180. }
  181. class EightWaySwipeDetector extends StatefulWidget {
  182. const EightWaySwipeDetector(
  183. {Key? key,
  184. required this.child,
  185. this.threshold = 50,
  186. required this.onSwipe})
  187. : super(key: key);
  188. final Widget child;
  189. final double threshold;
  190. final void Function(Offset dir)? onSwipe;
  191. @override
  192. State<EightWaySwipeDetector> createState() => _EightWaySwipeDetectorState();
  193. }
  194. class _EightWaySwipeDetectorState extends State<EightWaySwipeDetector> {
  195. Offset _startPos = Offset.zero;
  196. Offset _endPos = Offset.zero;
  197. bool _isSwiping = false;
  198. void _resetSwipe() {
  199. _startPos = _endPos = Offset.zero;
  200. _isSwiping = false;
  201. }
  202. ///这里主要是返回一个 -1 ~ 1 之间的数值,具体用于判断方向
  203. /// Offset(1.0, 0.0) 是手指右滑
  204. /// Offset(-1.0, 0.0) 是手指左滑
  205. /// Offset(0.0, 1.0) 是手指下滑
  206. /// Offset(0.0, -1.0) 是手指上滑
  207. void _maybeTriggerSwipe() {
  208. // Exit early if we're not currently swiping
  209. if (_isSwiping == false) return;
  210. /// 开始和结束位置计算出移动距离
  211. // Get the distance of the swipe
  212. Offset moveDelta = _endPos - _startPos;
  213. final distance = moveDelta.distance;
  214. /// 对比偏移量大小是否超过了 threshold ,不能小于 1
  215. // Trigger swipe if threshold has been exceeded, if threshold is < 1, use 1 as a minimum value.
  216. if (distance >= max(widget.threshold, 1)) {
  217. // Normalize the dx/dy values between -1 and 1
  218. moveDelta /= distance;
  219. // Round the dx/dy values to snap them to -1, 0 or 1, creating an 8-way directional vector.
  220. Offset dir = Offset(
  221. moveDelta.dx.roundToDouble(),
  222. moveDelta.dy.roundToDouble(),
  223. );
  224. widget.onSwipe?.call(dir);
  225. _resetSwipe();
  226. }
  227. }
  228. void _handleSwipeStart(d) {
  229. _isSwiping = true;
  230. _startPos = _endPos = d.localPosition;
  231. }
  232. void _handleSwipeUpdate(d) {
  233. _endPos = d.localPosition;
  234. _maybeTriggerSwipe();
  235. }
  236. void _handleSwipeEnd(d) {
  237. _maybeTriggerSwipe();
  238. _resetSwipe();
  239. }
  240. @override
  241. Widget build(BuildContext context) {
  242. return GestureDetector(
  243. behavior: HitTestBehavior.translucent,
  244. onPanStart: _handleSwipeStart,
  245. onPanUpdate: _handleSwipeUpdate,
  246. onPanCancel: _resetSwipe,
  247. onPanEnd: _handleSwipeEnd,
  248. child: widget.child);
  249. }
  250. }
  251. class _AnimatedCutoutOverlay extends StatelessWidget {
  252. const _AnimatedCutoutOverlay(
  253. {Key? key,
  254. required this.child,
  255. required this.cutoutSize,
  256. required this.animationKey,
  257. this.duration,
  258. required this.swipeDir,
  259. required this.opacity})
  260. : super(key: key);
  261. final Widget child;
  262. final Size cutoutSize;
  263. final Key animationKey;
  264. final Offset swipeDir;
  265. final Duration? duration;
  266. final double opacity;
  267. @override
  268. Widget build(BuildContext context) {
  269. return Stack(
  270. children: [
  271. child,
  272. // 用 ClipPath 做一个动画抠图
  273. Animate(
  274. effects: [
  275. CustomEffect(
  276. builder: _buildAnimatedCutout,
  277. curve: Curves.easeOut,
  278. duration: duration)
  279. ],
  280. key: animationKey,
  281. onComplete: (c) => c.reverse(),
  282. // 用一个黑色的蒙层,这里的 child 会变成 effects 里 builder 里的 child
  283. // 也就是黑色 Container 会在 _buildAnimatedCutout 作为 ClipPath 的 child
  284. child: IgnorePointer(
  285. child: Container(color: Colors.black.withOpacity(opacity))),
  286. ),
  287. ],
  288. );
  289. }
  290. /// Scales from 1 --> (1 - scaleAmt) --> 1
  291. Widget _buildAnimatedCutout(BuildContext context, double anim, Widget child) {
  292. // controls how much the center cutout will shrink when changing images
  293. const scaleAmt = .25;
  294. final size = Size(
  295. cutoutSize.width * (1 - scaleAmt * anim * swipeDir.dx.abs()),
  296. cutoutSize.height * (1 - scaleAmt * anim * swipeDir.dy.abs()),
  297. );
  298. print("### anim ${anim} ");
  299. return ClipPath(clipper: _CutoutClipper(size), child: child);
  300. }
  301. }
  302. /// Creates an overlay with a hole in the middle of a certain size.
  303. class _CutoutClipper extends CustomClipper<Path> {
  304. _CutoutClipper(this.cutoutSize);
  305. final Size cutoutSize;
  306. @override
  307. Path getClip(Size size) {
  308. double padX = (size.width - cutoutSize.width) / 2;
  309. double padY = (size.height - cutoutSize.height) / 2;
  310. return Path.combine(
  311. PathOperation.difference,
  312. Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
  313. Path()
  314. ..addRRect(
  315. RRect.fromLTRBR(
  316. padX,
  317. padY,
  318. size.width - padX,
  319. size.height - padY,
  320. Radius.circular(6),
  321. ),
  322. )
  323. ..close(),
  324. );
  325. }
  326. @override
  327. bool shouldReclip(_CutoutClipper oldClipper) =>
  328. oldClipper.cutoutSize != cutoutSize;
  329. }
  330. class ShowPathDifference extends StatelessWidget {
  331. @override
  332. Widget build(BuildContext context) {
  333. return Scaffold(
  334. appBar: AppBar(
  335. title: Text('ShowPathDifference'),
  336. ),
  337. body: Stack(
  338. alignment: Alignment.center,
  339. children: [
  340. Center(
  341. child: Container(
  342. width: 300,
  343. height: 300,
  344. decoration: BoxDecoration(
  345. image: DecorationImage(
  346. fit: BoxFit.cover,
  347. image: AssetImage("static/gsy_cat.png"),
  348. ),
  349. ),
  350. ),
  351. ),
  352. Center(
  353. child: CustomPaint(
  354. painter: ShowPathDifferencePainter(),
  355. ),
  356. ),
  357. ],
  358. ),
  359. );
  360. }
  361. }
  362. class ShowPathDifferencePainter extends CustomPainter {
  363. @override
  364. void paint(Canvas canvas, Size size) {
  365. final paint = Paint();
  366. paint.color = Colors.blue.withAlpha(160);
  367. canvas.drawPath(
  368. Path.combine(
  369. PathOperation.difference,
  370. Path()
  371. ..addRRect(
  372. RRect.fromLTRBR(-150, -150, 150, 150, Radius.circular(10))),
  373. Path()
  374. ..addOval(Rect.fromCircle(center: Offset(0, 0), radius: 100))
  375. ..close(),
  376. ),
  377. paint,
  378. );
  379. }
  380. @override
  381. bool shouldRepaint(CustomPainter oldDelegate) => false;
  382. }