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 GridView
realized , which is also the most interesting point in this effect, GridView
how 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.
GridView
The up, down, left, and right sides of the area should exceed the screen - 2.
GridView
How 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 OverflowBox
because 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, GridView
but up and down, so it can be simple Set a maxWidth
and maxHeight
to 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 " GridView
how 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 GridView
the sliding logic of and let GridView
only the layout, and the subsequent sliding logic GestureDetector
is implemented through a custom .
GridView.count(
physics: NeverScrollableScrollPhysics(),
As shown in the following code, we implement gesture recognition GestureDetector
by . The core point here _maybeTriggerSwipe
is the realization of . Its function is to obtain the direction result of gesture sliding. For the parameters of sliding that are greater threshold
than , 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 GridView
handle 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.translate
to move GridView
.
Yes, the sliding effect in this Photo Gallery is Transform.translate
achieved , 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 / 2
halfCount
- Calculate the size of an Item plus Padding
paddedImageSize
- Calculate the top-left of the default center position
originOffset
- Calculate the row and column position of the index to be moved
indexedOffset
- Finally, the two are subtracted (because
indexedOffset
there 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, GridView
under :
- Calculated by
halfCount
andpaddedImageSize
will get the position of the black dotted line - Red is the index position to be displayed, which
col
isrow
calculated by andindexedOffset
is 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 adx
positiveOffset
, the whole currentOffsetGridView
needs 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 GridView
whole is shifted. From moving the Item to be displayed to the center position, use Transform.translate
to achieve a similar sliding effect. Of course, will also be used in the TweenAnimationBuilder
implementation 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_animate
mainly ClipPath
realized through package and , as shown in the following code:
- Use
Animate
and add a black with transparency on topContainer
CustomEffect
Add custom animations using- Use it in the animation , and realize the "hollowing out" effect
ClipPath
by customizingCustomClipper
and combining the animation valuePathOperation.difference
The animation effect is obtained according
Animate
to the value ofcutoutSize
, the default is to1 - 0.25 * x
start , 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.difference
It can be seen from here that the highlighting effect is to “dig out” a blank Path on the black mask layer .
Tip 3:
PathOperation.difference
It 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;
}