mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-05-10 12:07:49 +08:00
599
lib/common/widgets/image_viewer/gallery_viewer.dart
Normal file
599
lib/common/widgets/image_viewer/gallery_viewer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
59
lib/common/widgets/image_viewer/hero_dialog_route.dart
Normal file
59
lib/common/widgets/image_viewer/hero_dialog_route.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
675
lib/common/widgets/image_viewer/image.dart
Normal file
675
lib/common/widgets/image_viewer/image.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
148
lib/common/widgets/image_viewer/loading_indicator.dart
Normal file
148
lib/common/widgets/image_viewer/loading_indicator.dart
Normal 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;
|
||||
}
|
||||
448
lib/common/widgets/image_viewer/viewer.dart
Normal file
448
lib/common/widgets/image_viewer/viewer.dart
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user