mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add swipe-down-to-dismiss for image preview
This commit is contained in:
@@ -116,22 +116,40 @@ class MediaImageViewerPage extends StatefulWidget {
|
||||
State<MediaImageViewerPage> createState() => _MediaImageViewerPageState();
|
||||
}
|
||||
|
||||
class _MediaImageViewerPageState extends State<MediaImageViewerPage> {
|
||||
class _MediaImageViewerPageState extends State<MediaImageViewerPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TransformationController _transformationController;
|
||||
late final AnimationController _snapBackController;
|
||||
late final CurvedAnimation _snapBackCurve;
|
||||
bool _isTransformed = false;
|
||||
bool _disableHeroOnDismiss = false;
|
||||
double _dragOffset = 0;
|
||||
bool _isDragging = false;
|
||||
|
||||
static const _dismissThreshold = 100.0;
|
||||
static const _backgroundFadeDivisor = 300.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_transformationController = TransformationController();
|
||||
_transformationController.addListener(_handleTransformChanged);
|
||||
_snapBackController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
_snapBackCurve = CurvedAnimation(
|
||||
parent: _snapBackController,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transformationController.removeListener(_handleTransformChanged);
|
||||
_transformationController.dispose();
|
||||
_snapBackCurve.dispose();
|
||||
_snapBackController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -143,6 +161,53 @@ class _MediaImageViewerPageState extends State<MediaImageViewerPage> {
|
||||
|
||||
setState(() {
|
||||
_isTransformed = isTransformed;
|
||||
// If the user zooms in while dragging, cancel the drag.
|
||||
if (_isTransformed && _isDragging) {
|
||||
_isDragging = false;
|
||||
_dragOffset = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onInteractionStart(ScaleStartDetails details) {
|
||||
if (!_isTransformed && details.pointerCount == 1) {
|
||||
_isDragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onInteractionUpdate(ScaleUpdateDetails details) {
|
||||
if (!_isDragging || _isTransformed || details.pointerCount > 1) return;
|
||||
setState(() {
|
||||
_dragOffset += details.focalPointDelta.dy;
|
||||
});
|
||||
}
|
||||
|
||||
void _onInteractionEnd(ScaleEndDetails details) {
|
||||
if (!_isDragging) return;
|
||||
_isDragging = false;
|
||||
|
||||
if (_dragOffset.abs() > _dismissThreshold) {
|
||||
_dismiss();
|
||||
} else {
|
||||
_animateSnapBack();
|
||||
}
|
||||
}
|
||||
|
||||
void _animateSnapBack() {
|
||||
final startOffset = _dragOffset;
|
||||
final tween = Tween<double>(begin: startOffset, end: 0);
|
||||
|
||||
void listener() {
|
||||
setState(() {
|
||||
_dragOffset = tween.evaluate(_snapBackCurve);
|
||||
});
|
||||
}
|
||||
|
||||
_snapBackController
|
||||
..reset()
|
||||
..addListener(listener);
|
||||
_snapBackController.forward().whenCompleteOrCancel(() {
|
||||
_snapBackController.removeListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,27 +245,40 @@ class _MediaImageViewerPageState extends State<MediaImageViewerPage> {
|
||||
},
|
||||
child: Scaffold(
|
||||
key: const ValueKey('message-media-image-viewer'),
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.black.withValues(
|
||||
alpha: (1 - (_dragOffset.abs() / _backgroundFadeDivisor)).clamp(
|
||||
0.3,
|
||||
1.0,
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
child: Center(
|
||||
child: HeroMode(
|
||||
key: const ValueKey('message-media-image-viewer-hero-mode'),
|
||||
enabled: !_disableHeroOnDismiss,
|
||||
child: Hero(
|
||||
tag: widget.heroTag,
|
||||
child: Image.network(
|
||||
widget.imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
errorBuilder: (_, _, _) => const _MediaLoadFailure(
|
||||
message: 'Failed to load image',
|
||||
icon: LucideIcons.imageOff,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _dragOffset),
|
||||
child: InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
onInteractionStart: _onInteractionStart,
|
||||
onInteractionUpdate: _onInteractionUpdate,
|
||||
onInteractionEnd: _onInteractionEnd,
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
child: Center(
|
||||
child: HeroMode(
|
||||
key: const ValueKey(
|
||||
'message-media-image-viewer-hero-mode',
|
||||
),
|
||||
enabled: !_disableHeroOnDismiss,
|
||||
child: Hero(
|
||||
tag: widget.heroTag,
|
||||
child: Image.network(
|
||||
widget.imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
errorBuilder: (_, _, _) => const _MediaLoadFailure(
|
||||
message: 'Failed to load image',
|
||||
icon: LucideIcons.imageOff,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user