Flutter tips to achieve a beautiful animated photo album effect

Today's tip is mainly to "plagiarize" a photo album control full of design sense. As shown in the figure below, it is the realization effect of a photo collection in the open source application of gskinner Wonderous . You can see that the photo album supports up, down, left, and right sliding, and has a highlighted display Animation effect, and the overall layout of the album can scroll beyond the screen , because it is an open source app, we only need to "copy" it to achieve the same effect, so if you want to achieve such an effect, your first reaction is what basic controls to use ?

Because you need to support free sliding up, down, left, and right, maybe your first reaction will be Table, or nest two ListView? But from the above effect experience, the sliding process of the control is not a linear effect of a normal Scroll control, because it is not in the state of "sliding with the finger".

Since it is an open source code, we can find that it is GridViewrealized , which is also the most interesting point in this effect, GridViewhow to turn a photo gallery into a photo gallery with animation.

So the core of this article is to analyze how the Photo Gallery in Wonderous is implemented, and to strip out simple codes .

Photo Gallery

To achieve the above-mentioned Photo Gallery effect, three core points need to be solved:

  • 1. GridViewThe up, down, left, and right sides of the area should exceed the screen
  • 2. GridViewHow to realize free switching between up, down, left and right
  • 3. Highlight the animation effect of the selected Item

First of all, the solution of the first point must be OverflowBoxbecause it supports the release of Child's layout constraints and allows Child to overflow the parent layout. Because the previous Photo Gallery is set to 5 Items in the horizontal direction, GridViewbut up and down, so it can be simple Set a maxWidthand maxHeightto be the size of the Child beyond the screen.

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

It can be seen that the requirement of "beyond the screen" is relatively simple, and the next question is " GridViewhow to realize free switching between up, down, left, and right".

Tip 1: Use OverflowBox on appropriate occasions to overflow the screen

By default GridView, definitely only supports sliding in one direction, so we simply prohibit GridViewthe sliding logic of and let GridViewonly the layout, and the subsequent sliding logic GestureDetectoris implemented through a custom .

GridView.count(
  physics: NeverScrollableScrollPhysics(),

As shown in the following code, we implement gesture recognition GestureDetectorby . The core point here _maybeTriggerSwipeis the realization of . Its function is to obtain the direction result of gesture sliding. For the parameters of sliding that are greater thresholdthan , the data will be changed to -1 through "sampling". 0 , 1 like this to represent the direction:

  • Offset(1.0, 0.0) is finger right slide
  • Offset(-1.0, 0.0) is finger left slide
  • Offset(0.0, 1.0) is finger down
  • Offset(0.0, -1.0) is finger slide up
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();
  }

  
  Widget build(BuildContext context) {
    
    
    return GestureDetector(
        behavior: HitTestBehavior.translucent,
        onPanStart: _handleSwipeStart,
        onPanUpdate: _handleSwipeUpdate,
        onPanCancel: _resetSwipe,
        onPanEnd: _handleSwipeEnd,
        child: widget.child);
  }
}

Tip 2: Offset.distance can be used to judge the size of the offset .

After knowing the direction of the gesture, we can GridViewhandle how to slide. Here we need to know which index should be displayed first.

By default, what we need to display is the middle Item. For example, when there are 25 Items, the index should be at 13th, and then we can adjust the next index according to the direction:

  • dy > 0 means that the finger slides down, that is, the page needs to go up, then the index needs to be -1, and vice versa is + 1
  • dx > 0 means sliding the finger to the right, that is, the page is going to the left, then the index needs to be -1, and vice versa is + 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(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}) {
    
    
    if (value < 0 || value >= _imgCount) return;
    setState(() => _index = value);
  }

Through the gesture direction, we can get the index of the next Item to be displayed, and then use Transform.translateto move GridView.

Yes, the sliding effect in this Photo Gallery is Transform.translateachieved , and one of the core is to calculate the Offset position that should be offset according to the direction :

  • First get one based on the number of horizontal directions / 2halfCount
  • Calculate the size of an Item plus PaddingpaddedImageSize
  • Calculate the top-left of the default center positionoriginOffset
  • Calculate the row and column position of the index to be movedindexedOffset
  • Finally, the two are subtracted (because indexedOffsetthere is a negative number) to get a relative offsetOffset
/// 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;
}

The specific points are shown in the figure below, for example, GridViewunder :

  • Calculated by halfCountand paddedImageSizewill get the position of the black dotted line
  • Red is the index position to be displayed, which col is rowcalculated by and indexedOffsetis the upper left corner of the red box, and the negative number used in the above code
  • When originOffset + indexedOffset, in fact, it is to get the currentOffset of the difference between the two. For example, if it is a dxpositive Offset, the whole currentOffset GridViewneeds to be moved to the left, and the red box is naturally displayed in the middle.

You can see this animation more vividly. The core is that the GridViewwhole is shifted. From moving the Item to be displayed to the center position, use Transform.translateto achieve a similar sliding effect. Of course, will also be used in the TweenAnimationBuilderimplementation to realize the animation process.

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(),

After solving the movement, the last thing is to realize the mask layer and highlight animation effect. The core of this is flutter_animatemainly ClipPathrealized through package and , as shown in the following code:

  • Use Animateand add a black with transparency on topContainer
  • CustomEffectAdd custom animations using
  • Use it in the animation , and realize the "hollowing out" effect ClipPathby customizing CustomClipperand combining the animation valuePathOperation.difference

The animation effect is obtained according Animateto the value of cutoutSize, the default is to 1 - 0.25 * xstart , where x is the sliding direction, and the final performance is the process from 0.75 to 1, so the animation will have an expansion effect from 0.75 to 1 according to the direction.


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()),
  );
  return ClipPath(clipper: _CutoutClipper(size), child: child);
}

class _CutoutClipper extends CustomClipper<Path> {
    
    
  _CutoutClipper(this.cutoutSize);

  final Size cutoutSize;

  
  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(),
    );
  }

  
  bool shouldReclip(_CutoutClipper oldClipper) =>
      oldClipper.cutoutSize != cutoutSize;
}

PathOperation.differenceIt can be seen from here that the highlighting effect is to “dig out” a blank Path on the black mask layer .

Tip 3: PathOperation.differenceIt can be used in scenes that need to be "hollowed out" .

For a more intuitive example, you can refer to an example, which is to perform a difference operation on two paths, and use Rect2 to eliminate the middle of Rect1 to obtain a "hollowed out" drawing Path in the middle.

class ShowPathDifference extends StatelessWidget {
    
    
  
  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 {
    
    
  
  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,
    );
  }

  
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

The final effect is as shown in the figure below. Here is the effect after stripping out the key part of the code in Wonderous. Because Wonderous does not package this part of the code as a package, so I stripped this part of the code and put it behind. If you are interested, you can Run it yourself to try the effect.

source code

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);

  
  State<PhotoGalleryDemoPage> createState() => _PhotoGalleryDemoPageState();
}

class _PhotoGalleryDemoPageState extends State<PhotoGalleryDemoPage> {
    
    
  
  Widget build(BuildContext context) {
    
    
    return PhotoGallery();
  }
}

class PhotoGallery extends StatefulWidget {
    
    
  const PhotoGallery({
    
    Key? key}) : super(key: key);

  
  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;
  }

  
  void initState() {
    
    
    colorList = List.generate(
        _imgCount,
        (index) => Color((Random().nextDouble() * 0xFFFFFF).toInt())
            .withOpacity(1));

    super.initState();
  }

  
  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;

  
  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();
  }

  
  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;

  
  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;

  
  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(),
    );
  }

  
  bool shouldReclip(_CutoutClipper oldClipper) =>
      oldClipper.cutoutSize != cutoutSize;
}

class ShowPathDifference extends StatelessWidget {
    
    
  
  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 {
    
    
  
  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,
    );
  }

  
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

Guess you like

Origin blog.csdn.net/ZuoYueLiang/article/details/129659968