今天的小技巧首要是「抄袭」一个充溢设计感的相册控件,如下图所示是 gskinner 开源运用 wonderous 里一个相片集的完成作用,能够看到相册支撑上下左右滑动,并带有高亮展现的动画作用,而且相册全体布局能够超出屏幕滚动,由于是开源的 App, 咱们只需求「照搬」就能够完成一摸一样的作用,那么如果要完成这样的作用,你榜首反响是用什么基础控件?

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

由于需求支撑上下左右自在滑动,可能咱们榜首反响会是 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 超出屏幕后巨细。

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

能够看到「超出屏幕」这个需求仍是比较简单,接下里便是 「GridView 怎么完成上下左右自在切换」这个问题。

小技巧 1 :在合适场合运用 OverflowBox 能够溢出屏幕

默许情况下 GridView 肯定只支撑一个方向滑动,所以干脆咱们制止 GridView 的滑动逻辑,让 GridView 只管布局,后边滑动逻辑经过自定义的 GestureDetector 来完成。

GridView.count(
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) 是手指上滑
class_EightWaySwipeDetectorStateextendsState<EightWaySwipeDetector>{
Offset_startPos=Offset.zero;
Offset_endPos=Offset.zero;
bool_isSwiping=false;
​
void_resetSwipe() {
 _startPos=_endPos=Offset.zero;
 _isSwiping=false;
}
​
///这儿首要是回来一个 -1 ~ 1 之间的数值,详细用于判别方向
/// Offset(1.0, 0.0) 是手指右滑
/// Offset(-1.0, 0.0) 是手指左滑
/// Offset(0.0, 1.0) 是手指下滑
/// Offset(0.0, -1.0) 是手指上滑
void_maybeTriggerSwipe() {
 // Exit early if we're not currently swiping
 if(_isSwiping==false)return;
​
 /// 开端和结束方位核算出移动间隔
 // Get the distance of the swipe
 OffsetmoveDelta=_endPos-_startPos;
 finaldistance=moveDelta.distance;
​
 /// 对比偏移量巨细是否超过了 threshold ,不能小于 1
 // Trigger swipe if threshold has been exceeded, if threshold is < 1, use 1 as a minimum value.
 if(distance>=max(widget.threshold,1)) {
  // Normalize the dx/dy values between -1 and 1
  moveDelta/=distance;
  // Round the dx/dy values to snap them to -1, 0 or 1, creating an 8-way directional vector.
  Offsetdir=Offset(
   moveDelta.dx.roundToDouble(),
   moveDelta.dy.roundToDouble(),
  );
  widget.onSwipe?.call(dir);
  _resetSwipe();
 }
}
​
void_handleSwipeStart(d) {
 _isSwiping=true;
 _startPos=_endPos=d.localPosition;
}
​
void_handleSwipeUpdate(d) {
 _endPos=d.localPosition;
 _maybeTriggerSwipe();
}
​
void_handleSwipeEnd(d) {
 _maybeTriggerSwipe();
 _resetSwipe();
}
​
@override
Widgetbuild(BuildContextcontext) {
 returnGestureDetector(
   behavior:HitTestBehavior.translucent,
   onPanStart:_handleSwipeStart,
   onPanUpdate:_handleSwipeUpdate,
   onPanCancel:_resetSwipe,
   onPanEnd:_handleSwipeEnd,
   child:widget.child);
}
}

小技巧 2:Offset.distance 能够用来作为判别偏移量的巨细

知道了手势方向之后,咱们就能够处理 GridView 应该怎么滑动,这儿咱们需求先知道当然应该展现哪个 index 。

默许情况下咱们需求展现的是最中心的 Item ,例如有 25 个 Item 的时分, index 应该在第 13 ,然后咱们再依据方历来调整下一个 index 是哪个:

  • dy > 0 ,便是手指下滑,也便是页面要往上,那么 index 就需求 -1,反过来便是 + 1
  • dx > 0 ,便是手指右滑,也便是页面要往左,那么 index 就需求 -1,反过来便是 + 1
// Index starts in the middle of the grid (eg, 25 items, index will start at 13)
int_index=((_gridSize*_gridSize)/2).round();
​
​
/// Converts a swipe direction into a new index
void_handleSwipe(Offsetdir) {
 // Calculate new index, y swipes move by an entire row, x swipes move one index at a time
 intnewIndex=_index;
​
 /// Offset(1.0, 0.0) 是手指右滑
 /// Offset(-1.0, 0.0) 是手指左滑
 /// Offset(0.0, 1.0) 是手指下滑
 /// Offset(0.0, -1.0) 是手指上滑/// dy > 0 ,便是手指下滑,也便是页面要往上,那么 index 就需求 -1,反过来便是 + 1
 if(dir.dy!=0)newIndex+=_gridSize*(dir.dy>0?-1:1);
​
 /// dx > 0 ,便是手指右滑,也便是页面要往左,那么 index 就需求 -1,反过来便是 + 1
 if(dir.dx!=0)newIndex+=(dir.dx>0?-1:1);
​
 ///这儿判别下 index 是不是超出方位
 // After calculating new index, exit early if we don't like it...
 if(newIndex<0||newIndex>_imgCount-1)
  return;// keep the index in range
 if(dir.dx<0&&newIndex%_gridSize==0)
  return;// prevent right-swipe when at right side
 if(dir.dx>0&&newIndex%_gridSize==_gridSize-1)
  return;// prevent left-swipe when at left side
 /// 响应
 _lastSwipeDir=dir;
 HapticFeedback.lightImpact();
 _setIndex(newIndex);
}
​
void_setIndex(intvalue, {boolskipAnimation=false}) {
 if(value<0||value>=_imgCount)return;
 setState(()=>_index=value);
}

经过手势方向,咱们就能够得到下一个需求展现的 Item 的 index 是什么,然后就能够运用 Transform.translate 来移动 GridView

是的,在这个 Photo Gallery 里的滑动作用是经过 Transform.translate 完成,中心之一也便是依据方向核算其应该偏移的 Offset 方位

  • 首先依据水平方向的数量 / 2 得到一个 halfCount
  • 核算出一个 Item 加上 Padding 巨细的 paddedImageSize
  • 核算出默许中心方位的 top-left 的 originOffset
  • 核算出要移动的 index 地点的行和列方位 indexedOffset
  • 终究两者相减(由于 indexedOffset 里是负数),得到一个相对的偏移 Offset
/// Determine the required offset to show the current selected index.
/// index=0 is top-left, and the index=max is bottom-right.
Offset_calculateCurrentOffset(doublepadding,Sizesize) {
/// 获取水平方向一半的巨细,默许也便是 2.0,由于 floorToDouble
doublehalfCount=(_gridSize/2).floorToDouble();
​
/// Item 巨细加上 Padding,也便是每个 Item 的实践巨细
SizepaddedImageSize=Size(size.width+padding,size.height+padding);
​
/// 核算出开端方位的 top-left
// Get the starting offset that would show the top-left image (index 0)
finaloriginOffset=Offset(
  halfCount*paddedImageSize.width,halfCount*paddedImageSize.height);
​
/// 得到要移动的 index 地点的行和列方位
// Add the offset for the row/col
intcol=_index%_gridSize;
introw=(_index/_gridSize).floor();
​
/// 负数核算出要移动的 index 的 top-left 方位,比方 index 比较小,那么这个 indexedOffset 就比中心点小,相减之后 Offset 就会是正数
/// 是不是有点懵逼?为什么正数 translate 会往 index 小的 方向移动??
/// 由于你代入的不对,咱们 translate 移动的是整个 GridView
/// 正数是向左向下移动,自然就把左面或许上面的 Item 显示出来
finalindexedOffset=
  Offset(-paddedImageSize.width*col,-paddedImageSize.height*row);
​
returnoriginOffset+indexedOffset;
}

详细点如下图所示,比方在 5 x 5 的 GridView 下:

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

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

更形象的能够看这个动画,中心便是整个 GridView 在发生了偏移,从把需求展现的 Item 移动到中心的方位,运用 Transform.translate 来完成类似滑动的作用,当然完成里还会用到 TweenAnimationBuilder 来完成动画进程,

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

TweenAnimationBuilder<Offset>(
 tween:Tween(begin:gridOffset,end:gridOffset),
 duration:offsetTweenDuration,
 curve:Curves.easeOut,
 builder: (_,value,child)=>
   Transform.translate(offset:value,child:child),
 child:GridView.count(
  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 的打开作用。

@override
Widgetbuild(BuildContextcontext) {
returnStack(
 children: [
  child,
  // 用 ClipPath 做一个动画抠图
  Animate(
   effects: [
    CustomEffect(
      builder:_buildAnimatedCutout,
      curve:Curves.easeOut,
      duration:duration)
   ],
   key:animationKey,
   onComplete: (c)=>c.reverse(),
   // 用一个黑色的蒙层,这儿的 child 会变成 effects 里 builder 里的 child
   // 也便是黑色 Container 会在 _buildAnimatedCutout 作为 ClipPath 的 child
   child:IgnorePointer(
     child:Container(color:Colors.black.withOpacity(opacity))),
  ),
 ],
);
}
​
/// Scales from 1 --> (1 - scaleAmt) --> 1
Widget_buildAnimatedCutout(BuildContextcontext,doubleanim,Widgetchild) {
// controls how much the center cutout will shrink when changing images
constscaleAmt=.25;
finalsize=Size(
 cutoutSize.width*(1-scaleAmt*anim*swipeDir.dx.abs()),
 cutoutSize.height*(1-scaleAmt*anim*swipeDir.dy.abs()),
);
returnClipPath(clipper:_CutoutClipper(size),child:child);
}
​
class_CutoutClipperextendsCustomClipper<Path>{
_CutoutClipper(this.cutoutSize);
​
finalSizecutoutSize;
​
@override
PathgetClip(Sizesize) {
 doublepadX=(size.width-cutoutSize.width)/2;
 doublepadY=(size.height-cutoutSize.height)/2;
​
 returnPath.combine(
  PathOperation.difference,
  Path()..addRect(Rect.fromLTWH(0,0,size.width,size.height)),
  Path()
   ..addRRect(
    RRect.fromLTRBR(
     padX,
     padY,
     size.width-padX,
     size.height-padY,
     Radius.circular(6),
    ),
   )
   ..close(),
 );
}
​
@override
boolshouldReclip(_CutoutClipperoldClipper)=>
  oldClipper.cutoutSize!=cutoutSize;
}

从这儿能够看到,其实高亮的作用便是在黑色的蒙层上,运用 PathOperation.difference 「挖」出来一个空白的 Path 。

小技巧 3 : PathOperation.difference 能够用在需求「镂空」 的场景上

更直观的能够参阅一下比方,便是对两个途径进行 difference 操作,,运用 Rect2 把 Rect1 中心给消除去,得到一个中心 「镂空」的绘制 Path。

classShowPathDifferenceextendsStatelessWidget{
@override
Widgetbuild(BuildContextcontext) {
 returnScaffold(
  appBar:AppBar(
   title:Text('ShowPathDifference'),
  ),
  body:Stack(
   alignment:Alignment.center,
   children: [
    Center(
     child:Container(
      width:300,
      height:300,
      decoration:BoxDecoration(
       image:DecorationImage(
        fit:BoxFit.cover,
        image:AssetImage("static/gsy_cat.png"),
       ),
      ),
     ),
    ),
    Center(
     child:CustomPaint(
      painter:ShowPathDifferencePainter(),
     ),
    ),
   ],
  ),
 );
}
}
​
classShowPathDifferencePainterextendsCustomPainter{
@override
voidpaint(Canvascanvas,Sizesize) {
 finalpaint=Paint();
 paint.color=Colors.blue.withAlpha(160);
 canvas.drawPath(
  Path.combine(
   PathOperation.difference,
   Path()
    ..addRRect(
      RRect.fromLTRBR(-150,-150,150,150,Radius.circular(10))),
   Path()
    ..addOval(Rect.fromCircle(center:Offset(0,0),radius:100))
    ..close(),
  ),
  paint,
 );
}
​
@override
boolshouldRepaint(CustomPainteroldDelegate)=>false;
}
​

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

终究作用如下图所依,这儿是把 wonderous 里要害部分代码剥离出来后的作用,由于 wonderous 并没有把这部分代码封装为 package ,所以我把这部分代码剥离出来放在了后边,感兴趣的能够自己运行试试作用。

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

源码

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// 来自  https://github.com/gskinnerTeam/flutter-wonderous-app 上的一个 UI 作用
class PhotoGalleryDemoPage extends StatefulWidget {
  const PhotoGalleryDemoPage({Key? key}) : super(key: key);
  @override
  State<PhotoGalleryDemoPage> createState() => _PhotoGalleryDemoPageState();
}
class _PhotoGalleryDemoPageState extends State<PhotoGalleryDemoPage> {
  @override
  Widget build(BuildContext context) {
    return PhotoGallery();
  }
}
class PhotoGallery extends StatefulWidget {
  const PhotoGallery({Key? key}) : super(key: key);
  @override
  State<PhotoGallery> createState() => _PhotoGalleryState();
}
class _PhotoGalleryState extends State<PhotoGallery> {
  static const int _gridSize = 5;
  late List<Color> colorList;
  // Index starts in the middle of the grid (eg, 25 items, index will start at 13)
  int _index = ((_gridSize * _gridSize) / 2).round();
  Offset _lastSwipeDir = Offset.zero;
  bool _skipNextOffsetTween = false;
  ///依据屏幕尺度,决议 Padding 的巨细,经过 scale 缩放
  _getPadding(Size size) {
    double scale = 1;
    final shortestSide = size.shortestSide;
    const tabletXl = 1000;
    const tabletLg = 800;
    const tabletSm = 600;
    const phoneLg = 400;
    if (shortestSide > tabletXl) {
      scale = 1.25;
    } else if (shortestSide > tabletLg) {
      scale = 1.15;
    } else if (shortestSide > tabletSm) {
      scale = 1;
    } else if (shortestSide > phoneLg) {
      scale = .9; // phone
    } else {
      scale = .85; // small phone
    }
    return 24 * scale;
  }
  int get _imgCount => pow(_gridSize, 2).round();
  Widget _buildImage(int index, Size imgSize) {
    /// Bind to collectibles.statesById because we might need to rebuild if a collectible is found.
    return ClipRRect(
      borderRadius: BorderRadius.circular(8),
      child: Container(
        width: imgSize.width,
        height: imgSize.height,
        color: colorList[index],
      ),
    );
  }
  /// Converts a swipe direction into a new index
  void _handleSwipe(Offset dir) {
    // Calculate new index, y swipes move by an entire row, x swipes move one index at a time
    int newIndex = _index;
    /// Offset(1.0, 0.0)  是手指右滑
    /// Offset(-1.0, 0.0) 是手指左滑
    /// Offset(0.0, 1.0)  是手指下滑
    /// Offset(0.0, -1.0) 是手指上滑
    /// dy > 0 ,便是手指下滑,也便是页面要往上,那么 index 就需求 -1,反过来便是 + 1
    if (dir.dy != 0) newIndex += _gridSize * (dir.dy > 0 ? -1 : 1);
    /// dx > 0 ,便是手指右滑,也便是页面要往左,那么 index 就需求 -1,反过来便是 + 1
    if (dir.dx != 0) newIndex += (dir.dx > 0 ? -1 : 1);
    ///这儿判别下 index 是不是超出方位
    // After calculating new index, exit early if we don't like it...
    if (newIndex < 0 || newIndex > _imgCount - 1)
      return; // keep the index in range
    if (dir.dx < 0 && newIndex % _gridSize == 0)
      return; // prevent right-swipe when at right side
    if (dir.dx > 0 && newIndex % _gridSize == _gridSize - 1)
      return; // prevent left-swipe when at left side
    /// 响应
    _lastSwipeDir = dir;
    HapticFeedback.lightImpact();
    _setIndex(newIndex);
  }
  void _setIndex(int value, {bool skipAnimation = false}) {
    print("######## $value");
    if (value < 0 || value >= _imgCount) return;
    _skipNextOffsetTween = skipAnimation;
    setState(() => _index = value);
  }
  /// Determine the required offset to show the current selected index.
  /// index=0 is top-left, and the index=max is bottom-right.
  Offset _calculateCurrentOffset(double padding, Size size) {
    /// 获取水平方向一半的巨细,默许也便是 2.0,由于 floorToDouble
    double halfCount = (_gridSize / 2).floorToDouble();
    /// Item 巨细加上 Padding,也便是每个 Item 的实践巨细
    Size paddedImageSize = Size(size.width + padding, size.height + padding);
    /// 核算出开端方位的 top-left
    // Get the starting offset that would show the top-left image (index 0)
    final originOffset = Offset(
        halfCount * paddedImageSize.width, halfCount * paddedImageSize.height);
    /// 得到要移动的 index 地点的行和列方位
    // Add the offset for the row/col
    int col = _index % _gridSize;
    int row = (_index / _gridSize).floor();
    /// 负数核算出要移动的 index 的 top-left 方位,比方 index 比较小,那么这个 indexedOffset 就比中心点小,相减之后 Offset 就会是正数
    /// 是不是有点懵逼?为什么正数 translate 会往 index 小的 方向移动??
    /// 由于你代入的不对,咱们 translate 移动的是整个 GridView
    /// 正数是向左向下移动,自然就把左面或许上面的 Item 显示出来
    final indexedOffset =
        Offset(-paddedImageSize.width * col, -paddedImageSize.height * row);
    return originOffset + indexedOffset;
  }
  @override
  void initState() {
    colorList = List.generate(
        _imgCount,
        (index) => Color((Random().nextDouble() * 0xFFFFFF).toInt())
            .withOpacity(1));
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    var mq = MediaQuery.of(context);
    var width = mq.size.width;
    var height = mq.size.height;
    bool isLandscape = mq.orientation == Orientation.landscape;
    ///依据横竖屏状况决议 Item 巨细
    Size imgSize = isLandscape
        ? Size(width * .5, height * .66)
        : Size(width * .66, height * .5);
    var padding = _getPadding(mq.size);
    final cutoutTweenDuration =
        _skipNextOffsetTween ? Duration.zero : Duration(milliseconds: 600) * .5;
    final offsetTweenDuration =
        _skipNextOffsetTween ? Duration.zero : Duration(milliseconds: 600) * .4;
    var gridOffset = _calculateCurrentOffset(padding, imgSize);
    gridOffset += Offset(0, -mq.padding.top / 2);
    //动画作用
    return _AnimatedCutoutOverlay(
      animationKey: ValueKey(_index),
      cutoutSize: imgSize,
      swipeDir: _lastSwipeDir,
      duration: cutoutTweenDuration,
      opacity: .7,
      child: SafeArea(
        bottom: false,
        // Place content in overflow box, to allow it to flow outside the parent
        child: OverflowBox(
          maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1),
          maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1),
          alignment: Alignment.center,
          // 手势获取方向上下左右
          child: EightWaySwipeDetector(
            onSwipe: _handleSwipe,
            threshold: 30,
            // A tween animation builder moves from image to image based on current offset
            child: TweenAnimationBuilder<Offset>(
                tween: Tween(begin: gridOffset, end: gridOffset),
                duration: offsetTweenDuration,
                curve: Curves.easeOut,
                builder: (_, value, child) =>
                    Transform.translate(offset: value, child: child),
                child: GridView.count(
                  physics: NeverScrollableScrollPhysics(),
                  crossAxisCount: _gridSize,
                  childAspectRatio: imgSize.aspectRatio,
                  mainAxisSpacing: padding,
                  crossAxisSpacing: padding,
                  children:
                      List.generate(_imgCount, (i) => _buildImage(i, imgSize)),
                )),
          ),
        ),
      ),
    );
  }
}
class EightWaySwipeDetector extends StatefulWidget {
  const EightWaySwipeDetector(
      {Key? key,
      required this.child,
      this.threshold = 50,
      required this.onSwipe})
      : super(key: key);
  final Widget child;
  final double threshold;
  final void Function(Offset dir)? onSwipe;
  @override
  State<EightWaySwipeDetector> createState() => _EightWaySwipeDetectorState();
}
class _EightWaySwipeDetectorState extends State<EightWaySwipeDetector> {
  Offset _startPos = Offset.zero;
  Offset _endPos = Offset.zero;
  bool _isSwiping = false;
  void _resetSwipe() {
    _startPos = _endPos = Offset.zero;
    _isSwiping = false;
  }
  ///这儿首要是回来一个 -1 ~ 1 之间的数值,详细用于判别方向
  /// Offset(1.0, 0.0)  是手指右滑
  /// Offset(-1.0, 0.0) 是手指左滑
  /// Offset(0.0, 1.0)  是手指下滑
  /// Offset(0.0, -1.0) 是手指上滑
  void _maybeTriggerSwipe() {
    // Exit early if we're not currently swiping
    if (_isSwiping == false) return;
    /// 开端和结束方位核算出移动间隔
    // Get the distance of the swipe
    Offset moveDelta = _endPos - _startPos;
    final distance = moveDelta.distance;
    /// 对比偏移量巨细是否超过了 threshold ,不能小于 1
    // Trigger swipe if threshold has been exceeded, if threshold is < 1, use 1 as a minimum value.
    if (distance >= max(widget.threshold, 1)) {
      // Normalize the dx/dy values between -1 and 1
      moveDelta /= distance;
      // Round the dx/dy values to snap them to -1, 0 or 1, creating an 8-way directional vector.
      Offset dir = Offset(
        moveDelta.dx.roundToDouble(),
        moveDelta.dy.roundToDouble(),
      );
      widget.onSwipe?.call(dir);
      _resetSwipe();
    }
  }
  void _handleSwipeStart(d) {
    _isSwiping = true;
    _startPos = _endPos = d.localPosition;
  }
  void _handleSwipeUpdate(d) {
    _endPos = d.localPosition;
    _maybeTriggerSwipe();
  }
  void _handleSwipeEnd(d) {
    _maybeTriggerSwipe();
    _resetSwipe();
  }
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        behavior: HitTestBehavior.translucent,
        onPanStart: _handleSwipeStart,
        onPanUpdate: _handleSwipeUpdate,
        onPanCancel: _resetSwipe,
        onPanEnd: _handleSwipeEnd,
        child: widget.child);
  }
}
class _AnimatedCutoutOverlay extends StatelessWidget {
  const _AnimatedCutoutOverlay(
      {Key? key,
      required this.child,
      required this.cutoutSize,
      required this.animationKey,
      this.duration,
      required this.swipeDir,
      required this.opacity})
      : super(key: key);
  final Widget child;
  final Size cutoutSize;
  final Key animationKey;
  final Offset swipeDir;
  final Duration? duration;
  final double opacity;
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        child,
        // 用 ClipPath 做一个动画抠图
        Animate(
          effects: [
            CustomEffect(
                builder: _buildAnimatedCutout,
                curve: Curves.easeOut,
                duration: duration)
          ],
          key: animationKey,
          onComplete: (c) => c.reverse(),
          // 用一个黑色的蒙层,这儿的 child 会变成 effects 里 builder 里的 child
          // 也便是黑色 Container 会在 _buildAnimatedCutout 作为 ClipPath 的 child
          child: IgnorePointer(
              child: Container(color: Colors.black.withOpacity(opacity))),
        ),
      ],
    );
  }
  /// Scales from 1 --> (1 - scaleAmt) --> 1
  Widget _buildAnimatedCutout(BuildContext context, double anim, Widget child) {
    // controls how much the center cutout will shrink when changing images
    const scaleAmt = .25;
    final size = Size(
      cutoutSize.width * (1 - scaleAmt * anim * swipeDir.dx.abs()),
      cutoutSize.height * (1 - scaleAmt * anim * swipeDir.dy.abs()),
    );
    print("### anim ${anim}   ");
    return ClipPath(clipper: _CutoutClipper(size), child: child);
  }
}
/// Creates an overlay with a hole in the middle of a certain size.
class _CutoutClipper extends CustomClipper<Path> {
  _CutoutClipper(this.cutoutSize);
  final Size cutoutSize;
  @override
  Path getClip(Size size) {
    double padX = (size.width - cutoutSize.width) / 2;
    double padY = (size.height - cutoutSize.height) / 2;
    return Path.combine(
      PathOperation.difference,
      Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
      Path()
        ..addRRect(
          RRect.fromLTRBR(
            padX,
            padY,
            size.width - padX,
            size.height - padY,
            Radius.circular(6),
          ),
        )
        ..close(),
    );
  }
  @override
  bool shouldReclip(_CutoutClipper oldClipper) =>
      oldClipper.cutoutSize != cutoutSize;
}
class ShowPathDifference extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ShowPathDifference'),
      ),
      body: Stack(
        alignment: Alignment.center,
        children: [
          Center(
            child: Container(
              width: 300,
              height: 300,
              decoration: BoxDecoration(
                image: DecorationImage(
                  fit: BoxFit.cover,
                  image: AssetImage("static/gsy_cat.png"),
                ),
              ),
            ),
          ),
          Center(
            child: CustomPaint(
              painter: ShowPathDifferencePainter(),
            ),
          ),
        ],
      ),
    );
  }
}
class ShowPathDifferencePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    paint.color = Colors.blue.withAlpha(160);
    canvas.drawPath(
      Path.combine(
        PathOperation.difference,
        Path()
          ..addRRect(
              RRect.fromLTRBR(-150, -150, 150, 150, Radius.circular(10))),
        Path()
          ..addOval(Rect.fromCircle(center: Offset(0, 0), radius: 100))
          ..close(),
      ),
      paint,
    );
  }
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}