refa image preview

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-02-14 10:16:48 +08:00
parent 8e726f49b2
commit beb7eb1aea
14 changed files with 1935 additions and 2066 deletions

View File

@@ -0,0 +1,599 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:io' show File, Platform;
import 'dart:ui' as ui;
import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart';
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image_viewer/image.dart';
import 'package:PiliPlus/common/widgets/image_viewer/loading_indicator.dart';
import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/utils/extension/string_ext.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' hide Image, PageView;
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:get/get.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
///
/// created by dom on 2026/02/14
///
class GalleryViewer extends StatefulWidget {
const GalleryViewer({
super.key,
this.minScale = 1.0,
this.maxScale = 8.0,
required this.quality,
required this.sources,
this.initIndex = 1,
});
final double minScale;
final double maxScale;
final int quality;
final List<SourceModel> sources;
final int initIndex;
@override
State<GalleryViewer> createState() => _GalleryViewerState();
}
class _GalleryViewerState extends State<GalleryViewer>
with SingleTickerProviderStateMixin {
late Size _containerSize;
late final int _quality;
late final RxInt _currIndex;
late final List<GlobalKey> _keys;
Player? _player;
Player get _effectivePlayer => _player ??= Player();
VideoController? _videoController;
VideoController get _effectiveVideoController =>
_videoController ??= VideoController(_effectivePlayer);
late final PageController _pageController;
late final ImageTapGestureRecognizer _tapGestureRecognizer;
late final ImageHorizontalDragGestureRecognizer
_horizontalDragGestureRecognizer;
late final LongPressGestureRecognizer _longPressGestureRecognizer;
final Rx<Matrix4> _matrix = Rx(Matrix4.identity());
late final AnimationController _animateController;
late final Animation<Decoration> _opacityAnimation;
double dx = 0, dy = 0;
Offset _offset = Offset.zero;
bool _dragging = false;
bool get _isActive => _dragging || _animateController.isAnimating;
String _getActualUrl(String url) {
return _quality != 100
? ImageUtils.thumbnailUrl(url, _quality)
: url.http2https;
}
@override
void initState() {
super.initState();
_quality = Pref.previewQ;
_currIndex = widget.initIndex.obs;
_playIfNeeded(widget.initIndex);
_keys = List.generate(widget.sources.length, (_) => GlobalKey());
_pageController = PageController(initialPage: widget.initIndex);
final gestureSettings = MediaQuery.maybeGestureSettingsOf(Get.context!);
_tapGestureRecognizer = ImageTapGestureRecognizer()
..onTap = _onTap
..gestureSettings = gestureSettings;
if (PlatformUtils.isDesktop) {
_tapGestureRecognizer.onSecondaryTapUp = _showDesktopMenu;
}
_horizontalDragGestureRecognizer = ImageHorizontalDragGestureRecognizer()
..gestureSettings = gestureSettings;
_longPressGestureRecognizer = LongPressGestureRecognizer()
..onLongPress = _onLongPress
..gestureSettings = gestureSettings;
_animateController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_opacityAnimation = _animateController.drive(
DecorationTween(
begin: const BoxDecoration(color: Colors.black),
end: const BoxDecoration(color: Colors.transparent),
),
);
_animateController.addListener(_updateTransformation);
}
void _updateTransformation() {
final val = _animateController.value;
final scale = ui.lerpDouble(1.0, 0.25, val)!;
// Matrix4.identity()
// ..translateByDouble(size.width / 2, size.height / 2, 0, 1)
// ..translateByDouble(size.width * val * dx, size.height * val * dy, 0, 1)
// ..scaleByDouble(scale, scale, 1, 1)
// ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1);
final tmp = (1.0 - scale) / 2.0;
_matrix.value = Matrix4.diagonal3Values(scale, scale, scale)
..setTranslationRaw(
_containerSize.width * (val * dx + tmp),
_containerSize.height * (val * dy + tmp),
0,
);
}
void _updateMoveAnimation() {
dy = _offset.dy.sign;
if (dy == 0) {
dx = 0;
} else {
dx = _offset.dx / _offset.dy.abs();
}
}
bool isAnimating() => _animateController.value != 0;
void _onDragStart(ScaleStartDetails details) {
_dragging = true;
if (_animateController.isAnimating) {
_animateController.stop();
} else {
_offset = Offset.zero;
_animateController.value = 0.0;
}
_updateMoveAnimation();
}
void _onDragUpdate(ScaleUpdateDetails details) {
if (!_isActive || _animateController.isAnimating) {
return;
}
_offset += details.focalPointDelta;
_updateMoveAnimation();
if (!_animateController.isAnimating) {
_animateController.value = _offset.dy.abs() / _containerSize.height;
}
}
void _onDragEnd(ScaleEndDetails details) {
if (!_isActive || _animateController.isAnimating) {
return;
}
_dragging = false;
if (_animateController.isCompleted) {
return;
}
if (!_animateController.isDismissed) {
if (_animateController.value > 0.2) {
Get.back();
} else {
_animateController.reverse();
}
}
}
@override
void dispose() {
_player?.dispose();
_player = null;
_videoController = null;
_pageController.dispose();
_animateController
..removeListener(_updateTransformation)
..dispose();
_tapGestureRecognizer.dispose();
_longPressGestureRecognizer.dispose();
_currIndex.close();
_matrix.close();
if (widget.quality != _quality) {
for (final item in widget.sources) {
if (item.sourceType == SourceType.networkImage) {
CachedNetworkImageProvider(_getActualUrl(item.url)).evict();
}
}
}
super.dispose();
}
void _onPointerDown(PointerDownEvent event) {
_tapGestureRecognizer.addPointer(event);
_longPressGestureRecognizer.addPointer(event);
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: .opaque,
onPointerDown: _onPointerDown,
child: DecoratedBoxTransition(
decoration: _opacityAnimation,
child: Stack(
clipBehavior: .none,
children: [
LayoutBuilder(
builder: (context, constraints) {
_containerSize = constraints.biggest;
return Obx(
() => Transform(
transform: _matrix.value,
child:
PageView<ImageHorizontalDragGestureRecognizer>.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const CustomTabBarViewScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
itemCount: widget.sources.length,
itemBuilder: _itemBuilder,
horizontalDragGestureRecognizer: () =>
_horizontalDragGestureRecognizer,
),
),
);
},
),
_buildIndicator,
],
),
),
);
}
Widget get _buildIndicator => Positioned(
bottom: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Container(
padding:
MediaQuery.viewPaddingOf(context) +
const EdgeInsets.fromLTRB(12, 8, 20, 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
],
),
),
alignment: Alignment.center,
child: Obx(
() => Text(
"${_currIndex.value + 1}/${widget.sources.length}",
style: const TextStyle(color: Colors.white),
),
),
),
),
);
void _playIfNeeded(int index) {
final item = widget.sources[index];
if (item.sourceType == .livePhoto) {
_effectivePlayer.open(Media(item.liveUrl!));
}
}
void _onPageChanged(int index) {
_player?.pause();
_playIfNeeded(index);
_currIndex.value = index;
}
late final ValueChanged<int>? _onChangePage = widget.sources.length == 1
? null
: (int offset) {
final currPage = _pageController.page?.round() ?? 0;
final nextPage = (currPage + offset).clamp(
0,
widget.sources.length - 1,
);
if (nextPage != currPage) {
_pageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
}
};
Widget _itemBuilder(BuildContext context, int index) {
final item = widget.sources[index];
return Hero(
tag: item.url,
child: switch (item.sourceType) {
.fileImage => Image.file(
key: _keys[index],
File(item.url),
filterQuality: .low,
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
isAnimating: isAnimating,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
tapGestureRecognizer: _tapGestureRecognizer,
horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
),
.networkImage => Image(
key: _keys[index],
image: CachedNetworkImageProvider(_getActualUrl(item.url)),
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
tapGestureRecognizer: _tapGestureRecognizer,
horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) {
return child;
}
if (frame == null) {
if (widget.quality == _quality) {
return const SizedBox.expand();
} else {
return Image(
image: CachedNetworkImageProvider(
ImageUtils.thumbnailUrl(item.url, widget.quality),
),
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
isAnimating: isAnimating,
onDragStart: null,
onDragUpdate: null,
onDragEnd: null,
tapGestureRecognizer: _tapGestureRecognizer,
horizontalDragGestureRecognizer:
_horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
);
// final isLongPic = item.isLongPic;
// return CachedNetworkImage(
// fadeInDuration: Duration.zero,
// fadeOutDuration: Duration.zero,
// // fit: isLongPic ? .fitWidth : null,
// // alignment: isLongPic ? .topCenter : .center,
// imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality),
// placeholder: (_, _) => const SizedBox.expand(),
// );
}
}
return child;
},
loadingBuilder: loadingBuilder,
isAnimating: isAnimating,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
),
.livePhoto => Obx(
key: _keys[index],
() => _currIndex.value == index
? Viewer(
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: _containerSize,
childSize: _containerSize,
isAnimating: isAnimating,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
tapGestureRecognizer: _tapGestureRecognizer,
horizontalDragGestureRecognizer:
_horizontalDragGestureRecognizer,
onChangePage: _onChangePage,
child: AbsorbPointer(
child: Video(
controller: _effectiveVideoController,
fill: Colors.transparent,
),
),
)
: const SizedBox.shrink(),
),
},
);
}
void _onTap() {
EasyThrottle.throttle(
'VIEWER_TAP',
const Duration(milliseconds: 555),
Get.back,
);
}
void _onLongPress() {
final item = widget.sources[_currIndex.value];
if (item.sourceType == .fileImage) return;
HapticFeedback.mediumImpact();
showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (PlatformUtils.isMobile)
ListTile(
onTap: () {
Get.back();
ImageUtils.onShareImg(item.url);
},
dense: true,
title: const Text('分享', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
Utils.copyText(item.url);
},
dense: true,
title: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadImg([item.url]);
},
dense: true,
title: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
if (PlatformUtils.isDesktop)
ListTile(
onTap: () {
Get.back();
PageUtils.launchURL(item.url);
},
dense: true,
title: const Text('网页打开', style: TextStyle(fontSize: 14)),
)
else if (widget.sources.length > 1)
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadImg(
widget.sources.map((item) => item.url).toList(),
);
},
dense: true,
title: const Text('保存全部图片', style: TextStyle(fontSize: 14)),
),
if (item.sourceType == SourceType.livePhoto)
ListTile(
onTap: () {
Get.back();
ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width!,
height: item.height!,
);
},
dense: true,
title: Text(
'保存${Platform.isIOS ? ' Live Photo' : '视频'}',
style: const TextStyle(fontSize: 14),
),
),
],
),
),
);
}
void _showDesktopMenu(TapUpDetails details) {
final item = widget.sources[_currIndex.value];
if (item.sourceType == .fileImage) return;
showMenu(
context: context,
position: PageUtils.menuPosition(details.globalPosition),
items: [
PopupMenuItem(
height: 42,
onTap: () => Utils.copyText(item.url),
child: const Text('复制链接', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadImg([item.url]),
child: const Text('保存图片', style: TextStyle(fontSize: 14)),
),
PopupMenuItem(
height: 42,
onTap: () => PageUtils.launchURL(item.url),
child: const Text('网页打开', style: TextStyle(fontSize: 14)),
),
if (item.sourceType == SourceType.livePhoto)
PopupMenuItem(
height: 42,
onTap: () => ImageUtils.downloadLivePhoto(
url: item.url,
liveUrl: item.liveUrl!,
width: item.width!,
height: item.height!,
),
child: const Text('保存视频', style: TextStyle(fontSize: 14)),
),
],
);
}
Widget loadingBuilder(
BuildContext context,
Widget child,
ImageChunkEvent? loadingProgress,
) {
if (loadingProgress != null) {
if (loadingProgress.cumulativeBytesLoaded !=
loadingProgress.expectedTotalBytes &&
loadingProgress.expectedTotalBytes != null) {
return Stack(
fit: .expand,
alignment: .center,
clipBehavior: .none,
children: [
child,
Center(
child: LoadingIndicator(
size: 39.4,
progress:
loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!,
),
),
],
);
}
}
return child;
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
/// https://github.com/qq326646683/interactiveviewer_gallery
/// A [PageRoute] with a semi transparent background.
///
/// Similar to calling [showDialog] except it can be used with a [Navigator] to
/// show a [Hero] animation.
class HeroDialogRoute<T> extends PageRoute<T> {
HeroDialogRoute({
required this.pageBuilder,
});
final RoutePageBuilder pageBuilder;
@override
bool get opaque => false;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => null;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
@override
bool get maintainState => true;
@override
Color? get barrierColor => null;
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: animation.drive(CurveTween(curve: Curves.easeOut)),
child: child,
);
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: pageBuilder(context, animation, secondaryAnimation),
);
}
}

View File

@@ -0,0 +1,675 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io' show File;
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
class Image extends StatefulWidget {
const Image({
super.key,
required this.image,
this.frameBuilder,
this.loadingBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
this.filterQuality = FilterQuality.medium,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.isAnimating,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.tapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
});
Image.network(
String src, {
super.key,
double scale = 1.0,
this.frameBuilder,
this.loadingBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
WebHtmlElementStrategy webHtmlElementStrategy =
WebHtmlElementStrategy.never,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.isAnimating,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.tapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
NetworkImage(
src,
scale: scale,
headers: headers,
webHtmlElementStrategy: webHtmlElementStrategy,
),
),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
Image.file(
File file, {
super.key,
double scale = 1.0,
this.frameBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.isAnimating,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.tapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : assert(
!kIsWeb,
'Image.file is not supported on Flutter Web. '
'Consider using either Image.asset or Image.network instead.',
),
image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
FileImage(file, scale: scale),
),
loadingBuilder = null,
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
Image.asset(
String name, {
super.key,
AssetBundle? bundle,
this.frameBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
double? scale,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
String? package,
this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.isAnimating,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.tapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
scale != null
? ExactAssetImage(
name,
bundle: bundle,
scale: scale,
package: package,
)
: AssetImage(name, bundle: bundle, package: package),
),
loadingBuilder = null,
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
Image.memory(
Uint8List bytes, {
super.key,
double scale = 1.0,
this.frameBuilder,
this.errorBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.isAntiAlias = false,
this.filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.isAnimating,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.tapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
}) : image = ResizeImage.resizeIfNeeded(
cacheWidth,
cacheHeight,
MemoryImage(bytes, scale: scale),
),
loadingBuilder = null,
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
final ImageProvider image;
final ImageFrameBuilder? frameBuilder;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
final double? width;
final double? height;
final Color? color;
final Animation<double>? opacity;
final FilterQuality filterQuality;
final BlendMode? colorBlendMode;
final BoxFit? fit;
final AlignmentGeometry alignment;
final ImageRepeat repeat;
final Rect? centerSlice;
final bool matchTextDirection;
final bool gaplessPlayback;
final String? semanticLabel;
final bool excludeFromSemantics;
final bool isAntiAlias;
final double minScale;
final double maxScale;
final Size containerSize;
final ValueGetter<bool> isAnimating;
final ValueChanged<ScaleStartDetails>? onDragStart;
final ValueChanged<ScaleUpdateDetails>? onDragUpdate;
final ValueChanged<ScaleEndDetails>? onDragEnd;
final ValueChanged<int>? onChangePage;
final ImageTapGestureRecognizer tapGestureRecognizer;
final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer;
@override
State<Image> createState() => _ImageState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<ImageProvider>('image', image))
..add(DiagnosticsProperty<Function>('frameBuilder', frameBuilder))
..add(
DiagnosticsProperty<Function>('loadingBuilder', loadingBuilder),
)
..add(DoubleProperty('width', width, defaultValue: null))
..add(DoubleProperty('height', height, defaultValue: null))
..add(ColorProperty('color', color, defaultValue: null))
..add(
DiagnosticsProperty<Animation<double>?>(
'opacity',
opacity,
defaultValue: null,
),
)
..add(
EnumProperty<BlendMode>(
'colorBlendMode',
colorBlendMode,
defaultValue: null,
),
)
..add(EnumProperty<BoxFit>('fit', fit, defaultValue: null))
..add(
DiagnosticsProperty<AlignmentGeometry>(
'alignment',
alignment,
defaultValue: null,
),
)
..add(
EnumProperty<ImageRepeat>(
'repeat',
repeat,
defaultValue: ImageRepeat.noRepeat,
),
)
..add(
DiagnosticsProperty<Rect>(
'centerSlice',
centerSlice,
defaultValue: null,
),
)
..add(
FlagProperty(
'matchTextDirection',
value: matchTextDirection,
ifTrue: 'match text direction',
),
)
..add(
StringProperty('semanticLabel', semanticLabel, defaultValue: null),
)
..add(
DiagnosticsProperty<bool>(
'this.excludeFromSemantics',
excludeFromSemantics,
),
)
..add(EnumProperty<FilterQuality>('filterQuality', filterQuality));
}
}
class _ImageState extends State<Image> with WidgetsBindingObserver {
ImageStream? _imageStream;
ImageInfo? _imageInfo;
ImageChunkEvent? _loadingProgress;
bool _isListeningToStream = false;
int? _frameNumber;
bool _wasSynchronouslyLoaded = false;
late DisposableBuildContext<State<Image>> _scrollAwareContext;
Object? _lastException;
StackTrace? _lastStack;
ImageStreamCompleterHandle? _completerHandle;
bool _isPaused = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<Image>>(this);
}
@override
void dispose() {
assert(_imageStream != null);
WidgetsBinding.instance.removeObserver(this);
_stopListeningToStream();
_completerHandle?.dispose();
_scrollAwareContext.dispose();
_replaceImage(info: null);
super.dispose();
}
@override
void didChangeDependencies() {
_resolveImage();
_isPaused =
!TickerMode.valuesOf(context).enabled ||
(MediaQuery.maybeDisableAnimationsOf(context) ?? false);
if (_isPaused && _frameNumber != null) {
_stopListeningToStream(keepStreamAlive: true);
} else {
_listenToStream();
}
super.didChangeDependencies();
}
@override
void didUpdateWidget(Image oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isListeningToStream &&
(widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) {
final ImageStreamListener oldListener = _getListener();
_imageStream!.addListener(_getListener(recreateListener: true));
_imageStream!.removeListener(oldListener);
}
if (widget.image != oldWidget.image) {
_resolveImage();
_listenToStream();
}
}
@override
void reassemble() {
_resolveImage();
super.reassemble();
}
void _resolveImage() {
final provider = ScrollAwareImageProvider<Object>(
context: _scrollAwareContext,
imageProvider: widget.image,
);
final ImageStream newStream = provider.resolve(
createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
: null,
),
);
_updateSourceStream(newStream);
}
ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) {
if (_imageStreamListener == null || recreateListener) {
_lastException = null;
_lastStack = null;
_imageStreamListener = ImageStreamListener(
_handleImageFrame,
onChunk: widget.loadingBuilder == null ? null : _handleImageChunk,
onError: widget.errorBuilder != null || kDebugMode
? (Object error, StackTrace? stackTrace) {
setState(() {
_lastException = error;
_lastStack = stackTrace;
});
assert(() {
if (widget.errorBuilder == null) {
throw error;
}
return true;
}());
}
: null,
);
}
return _imageStreamListener!;
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() {
_replaceImage(info: imageInfo);
_loadingProgress = null;
_lastException = null;
_lastStack = null;
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
});
if (_isPaused) {
_stopListeningToStream(keepStreamAlive: true);
}
}
void _handleImageChunk(ImageChunkEvent event) {
assert(widget.loadingBuilder != null);
setState(() {
_loadingProgress = event;
_lastException = null;
_lastStack = null;
});
}
void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo;
if (oldImageInfo != null) {
SchedulerBinding.instance.addPostFrameCallback(
(Duration duration) => oldImageInfo.dispose(),
debugLabel: 'Image.disposeOldInfo',
);
}
_imageInfo = info;
}
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream.key) {
return;
}
if (_isListeningToStream) {
_imageStream!.removeListener(_getListener());
}
if (!widget.gaplessPlayback) {
setState(() {
_replaceImage(info: null);
});
}
setState(() {
_loadingProgress = null;
_frameNumber = null;
_wasSynchronouslyLoaded = false;
});
_imageStream = newStream;
if (_isListeningToStream) {
_imageStream!.addListener(_getListener());
}
}
void _listenToStream() {
if (_isListeningToStream) {
return;
}
_isListeningToStream = true;
_imageStream!.addListener(_getListener());
_completerHandle?.dispose();
_completerHandle = null;
}
void _stopListeningToStream({bool keepStreamAlive = false}) {
if (!_isListeningToStream) {
return;
}
if (keepStreamAlive &&
_completerHandle == null &&
_imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
if (_imageStream!.completer != null && widget.errorBuilder != null) {
_imageStream!.completer!.addEphemeralErrorListener(
(
Object exception,
StackTrace? stackTrace,
) {},
);
}
_imageStream!.removeListener(_getListener());
_isListeningToStream = false;
}
Widget _debugBuildErrorWidget(BuildContext context, Object error) {
return Stack(
alignment: Alignment.center,
children: <Widget>[
const Positioned.fill(child: Placeholder(color: Color(0xCF8D021F))),
Padding(
padding: const EdgeInsets.all(4.0),
child: FittedBox(
child: Text(
'$error',
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
style: const TextStyle(
shadows: <Shadow>[Shadow(blurRadius: 1.0)],
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (_lastException != null) {
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, _lastException!, _lastStack);
}
if (kDebugMode) {
return _debugBuildErrorWidget(context, _lastException!);
}
}
Widget result;
if (_imageInfo != null) {
// final isLongPic =
// _imageInfo!.image.height / _imageInfo!.image.width >
// StyleString.imgMaxRatio;
result = Viewer(
minScale: widget.minScale,
maxScale: widget.maxScale,
containerSize: widget.containerSize,
childSize: Size(
_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble(),
),
isAnimating: widget.isAnimating,
onDragStart: widget.onDragStart,
onDragUpdate: widget.onDragUpdate,
onDragEnd: widget.onDragEnd,
tapGestureRecognizer: widget.tapGestureRecognizer,
horizontalDragGestureRecognizer: widget.horizontalDragGestureRecognizer,
onChangePage: widget.onChangePage,
child: RawImage(
image: _imageInfo!.image,
),
);
} else {
result = const SizedBox.expand();
}
if (!widget.excludeFromSemantics) {
result = Semantics(
container: widget.semanticLabel != null,
image: true,
label: widget.semanticLabel ?? '',
child: result,
);
}
if (widget.frameBuilder != null) {
result = widget.frameBuilder!(
context,
result,
_frameNumber,
_wasSynchronouslyLoaded,
);
}
if (widget.loadingBuilder != null) {
result = widget.loadingBuilder!(context, result, _loadingProgress);
}
return result;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description
..add(DiagnosticsProperty<ImageStream>('stream', _imageStream))
..add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo))
..add(
DiagnosticsProperty<ImageChunkEvent>(
'loadingProgress',
_loadingProgress,
),
)
..add(DiagnosticsProperty<int>('frameNumber', _frameNumber))
..add(
DiagnosticsProperty<bool>(
'wasSynchronouslyLoaded',
_wasSynchronouslyLoaded,
),
);
}
}

View File

@@ -0,0 +1,148 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:io' show Platform;
import 'dart:math' show pi;
import 'package:flutter/material.dart';
///
/// created by dom on 2026/02/14
///
class LoadingIndicator extends LeafRenderObjectWidget {
const LoadingIndicator({
super.key,
required this.size,
required this.progress,
});
final double size;
final double progress;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderLoadingIndicator(
preferredSize: size,
progress: progress,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderLoadingIndicator renderObject,
) {
renderObject
..preferredSize = size
..progress = progress;
}
}
class RenderLoadingIndicator extends RenderBox {
RenderLoadingIndicator({
required double preferredSize,
required double progress,
}) : _preferredSize = preferredSize,
_progress = progress;
double _preferredSize;
double get preferredSize => _preferredSize;
set preferredSize(double value) {
if (_preferredSize == value) return;
_preferredSize = value;
markNeedsLayout();
}
double _progress;
double get progress => _progress;
set progress(double value) {
if (_progress == value) return;
_progress = value;
markNeedsPaint();
}
@override
void performLayout() {
size = constraints.constrainDimensions(_preferredSize, _preferredSize);
}
@override
void paint(PaintingContext context, Offset offset) {
if (_progress == 0) {
return;
}
const padding = 8.0;
const strokeWidth = 1.4;
const startAngle = -pi / 2;
final paint = Paint()..isAntiAlias = true;
final size = this.size;
final radius = size.width / 2 - strokeWidth;
final center = size.center(.zero);
if (Platform.isIOS) {
context.canvas
..drawCircle(
center,
radius,
paint
..strokeWidth = strokeWidth
..color = Colors.white,
)
..drawCircle(
center,
radius - strokeWidth,
paint..color = const Color(0x80000000),
)
..drawArc(
Rect.fromCircle(center: center, radius: radius - padding),
startAngle,
progress * 2 * pi,
true,
paint..color = Colors.white,
);
} else {
context.canvas
..drawCircle(
center,
radius,
paint
..style = .fill
..color = const Color(0x80000000),
)
..drawCircle(
center,
radius,
paint
..style = .stroke
..strokeWidth = strokeWidth
..color = Colors.white,
)
..drawArc(
Rect.fromCircle(center: center, radius: radius - padding),
startAngle,
progress * 2 * pi,
true,
paint..style = .fill,
);
}
}
@override
bool get isRepaintBoundary => true;
}

View File

@@ -0,0 +1,448 @@
/*
* This file is part of PiliPlus
*
* PiliPlus is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PiliPlus is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PiliPlus. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart' show FrictionSimulation;
import 'package:flutter/services.dart' show HardwareKeyboard;
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_navigation/src/extension_navigation.dart'
show GetNavigation;
///
/// created by dom on 2026/02/14
///
class Viewer extends StatefulWidget {
const Viewer({
super.key,
required this.minScale,
required this.maxScale,
required this.containerSize,
required this.childSize,
required this.isAnimating,
required this.onDragStart,
required this.onDragUpdate,
required this.onDragEnd,
required this.tapGestureRecognizer,
required this.horizontalDragGestureRecognizer,
required this.onChangePage,
required this.child,
});
final double minScale;
final double maxScale;
final Size containerSize;
final Size childSize;
final Widget child;
final ValueGetter<bool> isAnimating;
final ValueChanged<ScaleStartDetails>? onDragStart;
final ValueChanged<ScaleUpdateDetails>? onDragUpdate;
final ValueChanged<ScaleEndDetails>? onDragEnd;
final ValueChanged<int>? onChangePage;
final ImageTapGestureRecognizer tapGestureRecognizer;
final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer;
@override
State<StatefulWidget> createState() => _ViewerState();
}
class _ViewerState extends State<Viewer> with SingleTickerProviderStateMixin {
static const double _interactionEndFrictionCoefficient = 0.0001; // 0.0000135
static const double _scaleFactor = kDefaultMouseScrollToScaleFactor;
_GestureType? _gestureType;
late double _scale;
double? _scaleStart;
late Offset _position;
Offset? _referenceFocalPoint;
late Size _imageSize;
late final ImageTapGestureRecognizer _tapGestureRecognizer;
late final ImageHorizontalDragGestureRecognizer
_horizontalDragGestureRecognizer;
late final ScaleGestureRecognizer _scaleGestureRecognizer;
late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer;
Offset? _downPos;
AnimationController? _animationController;
AnimationController get _effectiveAnimationController =>
_animationController ??= AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addListener(_listener);
late final _tween = Matrix4Tween();
late final _animatable = _tween.chain(CurveTween(curve: Curves.easeOut));
void _listener() {
final storage = _animatable.evaluate(_effectiveAnimationController);
_scale = storage[0];
_position = Offset(storage[12], storage[13]);
setState(() {});
}
void _reset() {
_scale = 1.0;
_position = .zero;
}
void _initSize() {
_reset();
_imageSize = applyBoxFit(
.scaleDown,
widget.childSize,
widget.containerSize,
).destination;
// if (_imageSize.height / _imageSize.width > StyleString.imgMaxRatio) {
// _imageSize = applyBoxFit(
// .fitWidth,
// widget.childSize,
// widget.containerSize,
// ).destination;
// final containerWidth = widget.containerSize.width;
// final containerHeight = widget.containerSize.height;
// _scale = containerWidth / _imageSize.width;
// final imageHeight = _imageSize.height * _scale;
// _position = Offset(
// (1 - _scale) * containerWidth / 2,
// (imageHeight - _scale * containerHeight) / 2,
// );
// }
}
@override
void initState() {
super.initState();
_initSize();
_tapGestureRecognizer = widget.tapGestureRecognizer;
_horizontalDragGestureRecognizer = widget.horizontalDragGestureRecognizer;
final gestureSettings = MediaQuery.maybeGestureSettingsOf(Get.context!);
_scaleGestureRecognizer = ScaleGestureRecognizer(debugOwner: this)
..dragStartBehavior = .start
..onStart = _onScaleStart
..onUpdate = _onScaleUpdate
..onEnd = _onScaleEnd
..gestureSettings = gestureSettings;
_doubleTapGestureRecognizer = DoubleTapGestureRecognizer(debugOwner: this)
..onDoubleTapDown = _onDoubleTapDown
..onDoubleTap = _onDoubleTap
..gestureSettings = gestureSettings;
}
@override
void didUpdateWidget(Viewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.containerSize != widget.containerSize ||
oldWidget.childSize != widget.childSize) {
_initSize();
}
}
@override
void dispose() {
_animationController
?..removeListener(_listener)
..dispose();
_animationController = null;
_scaleGestureRecognizer.dispose();
_doubleTapGestureRecognizer.dispose();
super.dispose();
}
Offset _toScene(Offset localFocalPoint) {
return (localFocalPoint - _position) / _scale;
}
Offset _clampPosition(Offset offset, double scale) {
final containerSize = widget.containerSize;
final containerWidth = containerSize.width;
final containerHeight = containerSize.height;
final imageWidth = _imageSize.width * scale;
final imageHeight = _imageSize.height * scale;
final dx = (1 - scale) * containerWidth / 2;
final dxOffset = (imageWidth - containerWidth) / 2;
final dy = (1 - scale) * containerHeight / 2;
final dyOffset = (imageHeight - containerHeight) / 2;
return Offset(
imageWidth > containerWidth
? clampDouble(offset.dx, dx - dxOffset, dx + dxOffset)
: dx,
imageHeight > containerHeight
? clampDouble(offset.dy, dy - dyOffset, dy + dyOffset)
: dy,
);
}
Offset _matrixTranslate(Offset translation) {
if (translation == .zero) {
return _position;
}
return _clampPosition(_position + translation * _scale, _scale);
}
void _onDoubleTapDown(TapDownDetails details) {
_downPos = details.localPosition;
}
void _onDoubleTap() {
EasyThrottle.throttle(
'VIEWER_TAP',
const Duration(milliseconds: 555),
_handleDoubleTap,
);
}
void _handleDoubleTap() {
final Matrix4 begin;
final Matrix4 end;
if (_scale == 1.0) {
final imageWidth = _imageSize.width;
final imageHeight = _imageSize.height;
final isLongPic = imageHeight / imageWidth >= StyleString.imgMaxRatio;
double scale = widget.maxScale * 0.6;
if (isLongPic) {
scale = widget.containerSize.width / _imageSize.width;
} else {
scale = widget.maxScale * 0.6;
}
if (scale <= widget.minScale) {
scale = widget.maxScale;
}
begin = Matrix4.identity();
final position = _clampPosition(_downPos! * (1 - scale), scale);
end = Matrix4.identity()
..translateByDouble(position.dx, position.dy, 0.0, 1.0)
..scaleByDouble(scale, scale, scale, 1.0);
} else {
begin = Matrix4.identity()
..translateByDouble(_position.dx, _position.dy, 0.0, 1.0)
..scaleByDouble(_scale, _scale, _scale, 1.0);
end = Matrix4.identity();
}
_tween
..begin = begin
..end = end;
_effectiveAnimationController
..duration = const Duration(milliseconds: 300)
..forward(from: 0);
}
void _onScaleStart(ScaleStartDetails details) {
if (widget.isAnimating() || (details.pointerCount < 2 && _scale == 1.0)) {
widget.onDragStart?.call(details);
return;
}
_scaleStart = _scale;
_referenceFocalPoint = _toScene(details.localFocalPoint);
}
void _onScaleUpdate(ScaleUpdateDetails details) {
if (widget.isAnimating() || (details.pointerCount < 2 && _scale == 1.0)) {
widget.onDragUpdate?.call(details);
return;
}
if (details.scale != 1.0) {
_gestureType = .scale;
_scale = clampDouble(
_scaleStart! * details.scale,
widget.minScale,
widget.maxScale,
);
final Offset focalPointSceneScaled = _toScene(details.localFocalPoint);
_position = _matrixTranslate(
focalPointSceneScaled - _referenceFocalPoint!,
);
setState(() {});
} else {
_gestureType = .pan;
final Offset focalPointScene = _toScene(details.localFocalPoint);
final Offset translationChange = focalPointScene - _referenceFocalPoint!;
_position = _matrixTranslate(translationChange);
_referenceFocalPoint = _toScene(details.localFocalPoint);
setState(() {});
}
}
/// ref [InteractiveViewer]
void _onScaleEnd(ScaleEndDetails details) {
if (widget.isAnimating() || (details.pointerCount < 2 && _scale == 1.0)) {
widget.onDragEnd?.call(details);
return;
}
switch (_gestureType) {
case _GestureType.pan:
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
return;
}
final FrictionSimulation frictionSimulationX = FrictionSimulation(
_interactionEndFrictionCoefficient,
_position.dx,
details.velocity.pixelsPerSecond.dx,
);
final FrictionSimulation frictionSimulationY = FrictionSimulation(
_interactionEndFrictionCoefficient,
_position.dy,
details.velocity.pixelsPerSecond.dy,
);
final double tFinal = _getFinalTime(
details.velocity.pixelsPerSecond.distance,
_interactionEndFrictionCoefficient,
);
final position = _clampPosition(
Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
_scale,
);
_tween
..begin = (Matrix4.identity()
..translateByDouble(_position.dx, _position.dy, 0.0, 1.0)
..scaleByDouble(_scale, _scale, _scale, 1.0))
..end = (Matrix4.identity()
..translateByDouble(position.dx, position.dy, 0.0, 1.0)
..scaleByDouble(_scale, _scale, _scale, 1.0));
_effectiveAnimationController
..duration = Duration(milliseconds: (tFinal * 1000).round())
..forward(from: 0);
case _GestureType.scale:
// if (details.scaleVelocity.abs() < 0.1) {
// return;
// }
// final double scale = _scale;
// final FrictionSimulation frictionSimulation = FrictionSimulation(
// _interactionEndFrictionCoefficient * _scaleFactor,
// scale,
// details.scaleVelocity / 10,
// );
// final double tFinal = _getFinalTime(
// details.scaleVelocity.abs(),
// _interactionEndFrictionCoefficient,
// effectivelyMotionless: 0.1,
// );
// _scaleAnimation = _scaleController.drive(
// Tween<double>(
// begin: scale,
// end: frictionSimulation.x(tFinal),
// ).chain(CurveTween(curve: Curves.decelerate)),
// )..addListener(_handleScaleAnimation);
// _effectiveAnimationController
// ..duration = Duration(milliseconds: (tFinal * 1000).round())
// ..forward(from: 0);
case null:
}
_gestureType = null;
}
@override
Widget build(BuildContext context) {
final matrix = Matrix4.identity()
..translateByDouble(_position.dx, _position.dy, 0.0, 1.0)
..scaleByDouble(_scale, _scale, _scale, 1.0);
return Listener(
behavior: .opaque,
onPointerDown: _onPointerDown,
onPointerPanZoomStart: _onPointerPanZoomStart,
onPointerSignal: _onPointerSignal,
child: ClipRRect(
child: Transform(
transform: matrix,
child: widget.child,
),
),
);
}
void _onPointerDown(PointerDownEvent event) {
_tapGestureRecognizer.addPointer(event);
_doubleTapGestureRecognizer.addPointer(event);
_horizontalDragGestureRecognizer
..isBoundaryAllowed = _isBoundaryAllowed
..addPointer(event);
_scaleGestureRecognizer.addPointer(event);
}
void _onPointerPanZoomStart(PointerPanZoomStartEvent event) {
_scaleGestureRecognizer.addPointerPanZoom(event);
}
bool _isBoundaryAllowed(Offset? initialPosition, OffsetPair lastPosition) {
if (initialPosition == null) {
return true;
}
if (_scale <= 1.0) {
return true;
}
final containerWidth = widget.containerSize.width;
final imageWidth = _imageSize.width * _scale;
final dx = (1 - _scale) * containerWidth / 2;
final dxOffset = (imageWidth - containerWidth) / 2;
if (initialPosition.dx < lastPosition.global.dx) {
return _position.dx == dx + dxOffset;
} else {
return _position.dx == dx - dxOffset;
}
}
void _onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
if (widget.onChangePage != null &&
!HardwareKeyboard.instance.isControlPressed) {
widget.onChangePage!.call(event.scrollDelta.dy < 0 ? -1 : 1);
return;
}
final double scaleChange = math.exp(-event.scrollDelta.dy / _scaleFactor);
final Offset local = event.localPosition;
final Offset focalPointScene = _toScene(local);
_scale = clampDouble(
_scale * scaleChange,
widget.minScale,
widget.maxScale,
);
final Offset focalPointSceneScaled = _toScene(local);
_position = _matrixTranslate(focalPointSceneScaled - focalPointScene);
setState(() {});
}
}
}
enum _GestureType { pan, scale }
double _getFinalTime(
double velocity,
double drag, {
double effectivelyMotionless = 10,
}) {
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
}