如何制作这个ScrollView?
如何实现对角滚动?
我尝试使用diagnostic_scrollview 但失败了。
Wonderous 是开源的,您只需查看原始实现并尝试了解它是如何实现的。在代码中,所有方向的滑动都是由八路滑动检测器(EightWaySwipeDetector)处理的。
试试这个
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:sized_context/sized_context.dart';
// part 'widgets/_animated_cutout_overlay.dart';
class PhotoGallery extends StatefulWidget {
const PhotoGallery({super.key, this.imageSize, required this.collectionId});
final Size? imageSize;
final String collectionId;
// final WonderType wonderType;
@override
State<PhotoGallery> createState() => _PhotoGalleryState();
}
class _PhotoGalleryState extends State<PhotoGallery> {
static const int _gridSize = 5;
// 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;
final double _scale = 1;
bool _skipNextOffsetTween = false;
late Duration swipeDuration = const Duration(milliseconds: 200);
final _photoIds = ValueNotifier<List<String>>([]);
int get _imgCount => pow(_gridSize, 2).round();
late final List<FocusNode> _focusNodes =
List.generate(_imgCount, (index) => FocusNode());
@override
void initState() {
super.initState();
_initPhotoIds();
_focusNodes[_index].requestFocus();
}
Future<void> _initPhotoIds() async {
setState(() {
_photoIds.value = [
'https://picsum.photos/200?random=0',
'https://picsum.photos/200?random=1',
'https://picsum.photos/200?random=2',
'https://picsum.photos/200?random=3',
'https://picsum.photos/200?random=4',
'https://picsum.photos/200?random=5',
'https://picsum.photos/200?random=6',
'https://picsum.photos/200?random=7',
'https://picsum.photos/200?random=8',
'https://picsum.photos/200?random=9',
'https://picsum.photos/200?random=10',
'https://picsum.photos/200?random=11',
'https://picsum.photos/200?random=12',
'https://picsum.photos/200?random=13',
'https://picsum.photos/200?random=14',
'https://picsum.photos/200?random=15',
'https://picsum.photos/200?random=16',
'https://picsum.photos/200?random=17',
'https://picsum.photos/200?random=18',
'https://picsum.photos/200?random=19',
'https://picsum.photos/200?random=20',
'https://picsum.photos/200?random=21',
'https://picsum.photos/200?random=22',
'https://picsum.photos/200?random=23',
'https://picsum.photos/200?random=24',
'https://picsum.photos/200?random=25',
];
});
}
void _setIndex(int value, {bool skipAnimation = false}) {
if (value < 0 || value >= _imgCount) return;
_skipNextOffsetTween = skipAnimation;
setState(() => _index = value);
_focusNodes[value].requestFocus();
}
/// 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) {
double halfCount = (_gridSize / 2).floorToDouble();
Size paddedImageSize = Size(size.width + padding, size.height + padding);
// Get the starting offset that would show the top-left image (index 0)
final originOffset = Offset(
halfCount * paddedImageSize.width, halfCount * paddedImageSize.height);
// Add the offset for the row/col
int col = _index % _gridSize;
int row = (_index / _gridSize).floor();
final indexedOffset =
Offset(-paddedImageSize.width * col, -paddedImageSize.height * row);
return originOffset + indexedOffset;
}
/// 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;
if (dir.dy != 0) newIndex += _gridSize * (dir.dy > 0 ? -1 : 1);
if (dir.dx != 0) newIndex += (dir.dx > 0 ? -1 : 1);
// 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;
// AppHaptics.lightImpact();
_setIndex(newIndex);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<String>>(
valueListenable: _photoIds,
builder: (_, value, __) {
if (value.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
Size imgSize = context.isLandscape
? Size(context.widthPx * .5, context.heightPx * .66)
: Size(context.widthPx * .66, context.heightPx * .5);
imgSize = (widget.imageSize ?? imgSize) * _scale;
// Get transform offset for the current _index
const padding = 16.0;
var gridOffset = _calculateCurrentOffset(padding, imgSize);
gridOffset += Offset(0, -context.mq.padding.top / 2);
final offsetTweenDuration =
_skipNextOffsetTween ? Duration.zero : swipeDuration;
final cutoutTweenDuration =
_skipNextOffsetTween ? Duration.zero : swipeDuration * .5;
return _AnimatedCutoutOverlay(
animationKey: ValueKey(_index),
cutoutSize: imgSize,
swipeDir: _lastSwipeDir,
duration: cutoutTweenDuration,
opacity: _scale == 1 ? .7 : .5,
enabled: true,
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,
// Detect swipes in order to change index
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: FocusTraversalGroup(
//policy: OrderedTraversalPolicy(),
child: GridView.count(
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: _gridSize,
childAspectRatio: imgSize.aspectRatio,
mainAxisSpacing: padding,
crossAxisSpacing: padding,
children: List.generate(_imgCount,
(i) => _buildImage(i, swipeDuration, imgSize)),
),
),
),
),
),
),
);
});
}
Widget _buildImage(int index, Duration swipeDuration, Size imgSize) {
/// Bind to collectibles.statesById because we might need to rebuild if a collectible is found.
return FocusTraversalOrder(
order: NumericFocusOrder(index.toDouble()),
child: ValueListenableBuilder(
valueListenable: _photoIds,
builder: (_, __, ___) {
bool isSelected = index == _index;
final imgUrl = _photoIds.value[index];
final photoWidget = TweenAnimationBuilder<double>(
duration: swipeDuration,
curve: Curves.easeOut,
tween: Tween(begin: 1, end: 1),
builder: (_, value, child) =>
Transform.scale(scale: value, child: child),
child: Image.network(
imgUrl,
fit: BoxFit.cover,
width: imgSize.width,
height: imgSize.height,
).animate().fade(),
);
return isSelected
? Center(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(10),
),
clipBehavior: Clip.hardEdge,
child: photoWidget),
)
: Container(
// Suggested code may be subject to a license. Learn more: ~LicenseLog:1894125391.
color: Colors.grey,
child: photoWidget,
);
}),
);
// return Container(
// color: Colors.red,
// );
}
}
class _AnimatedCutoutOverlay extends StatelessWidget {
const _AnimatedCutoutOverlay({
required this.child,
required this.cutoutSize,
required this.animationKey,
this.duration,
required this.swipeDir,
required this.opacity,
required this.enabled,
});
final Widget child;
final Size cutoutSize;
final Key animationKey;
final Offset swipeDir;
final Duration? duration;
final double opacity;
final bool enabled;
@override
Widget build(BuildContext context) {
if (!enabled) return child;
return Stack(
children: [
child,
Animate(
effects: [
CustomEffect(
builder: _buildAnimatedCutout,
curve: Curves.easeOut,
duration: duration)
],
key: animationKey,
onComplete: (c) => c.reverse(),
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);
}
}
/// 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,
const Radius.circular(6),
),
)
..close(),
);
}
@override
bool shouldReclip(_CutoutClipper oldClipper) =>
oldClipper.cutoutSize != cutoutSize;
}
class EightWaySwipeDetector extends StatefulWidget {
const EightWaySwipeDetector(
{super.key,
required this.child,
this.threshold = 50,
required this.onSwipe});
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;
}
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;
// 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);
}
}