From 5645d805f56e1f9f4646ef6c41096feb6dab3d87 Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 22 Feb 2025 10:41:56 +0800 Subject: [PATCH] Improve changing chapter gesture with continuous mode. --- assets/translation.json | 8 +- lib/pages/reader/images.dart | 238 ++++++++++++++++++++++++++++++--- lib/pages/reader/scaffold.dart | 129 ++++-------------- 3 files changed, 254 insertions(+), 121 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index 8d4f849..801d496 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -361,7 +361,9 @@ "Last Reading: @epName Page @page": "上次阅读: @epName 第 @page 页", "WebDAV Auto Sync": "WebDAV 自动同步", "Mark all as read": "全部标记为已读", - "Do you want to mark all as read?" : "您要全部标记为已读吗?" + "Do you want to mark all as read?" : "您要全部标记为已读吗?", + "Swipe down for previous chapter": "向下滑动查看上一章", + "Swipe up for next chapter": "向上滑动查看下一章" }, "zh_TW": { "Home": "首頁", @@ -725,6 +727,8 @@ "Last Reading: @epName Page @page": "上次閱讀: @epName 第 @page 頁", "WebDAV Auto Sync": "WebDAV 自動同步", "Mark all as read": "全部標記為已讀", - "Do you want to mark all as read?" : "您要全部標記為已讀嗎?" + "Do you want to mark all as read?" : "您要全部標記為已讀嗎?", + "Swipe down for previous chapter": "向下滑動查看上一章", + "Swipe up for next chapter": "向上滑動查看下一章" } } \ No newline at end of file diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index 5ce0e56..24b1ac8 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -154,7 +154,6 @@ class _GalleryModeState extends State<_GalleryMode> builder: (BuildContext context, int index) { if (index == 0 || index == totalPages + 1) { return PhotoViewGalleryPageOptions.customChild( - scaleStateController: PhotoViewScaleStateController(), child: const SizedBox(), ); } else { @@ -168,7 +167,7 @@ class _GalleryModeState extends State<_GalleryMode> cached[index] = true; cache(index); - photoViewControllers[index] = PhotoViewController(); + photoViewControllers[index] ??= PhotoViewController(); if (reader.imagesPerPage == 1) { return PhotoViewGalleryPageOptions( @@ -350,6 +349,8 @@ const Set _kTouchLikeDeviceTypes = { PointerDeviceKind.unknown }; +const double _kChangeChapterOffset = 200; + class _ContinuousMode extends StatefulWidget { const _ContinuousMode({super.key}); @@ -364,7 +365,9 @@ class _ContinuousModeState extends State<_ContinuousMode> var itemScrollController = ItemScrollController(); var itemPositionsListener = ItemPositionsListener.create(); var photoViewController = PhotoViewController(); - late ScrollController scrollController; + ScrollController? _scrollController; + + ScrollController get scrollController => _scrollController!; var isCTRLPressed = false; static var _isMouseScrolling = false; @@ -372,6 +375,7 @@ class _ContinuousModeState extends State<_ContinuousMode> bool disableScroll = false; late List cached; + int get preCacheCount => appdata.settings["preloadImageCount"]; /// Whether the user was scrolling the page. @@ -386,6 +390,11 @@ class _ContinuousModeState extends State<_ContinuousMode> ); } + bool prepareToPrevChapter = false; + bool prepareToNextChapter = false; + bool jumpToNextChapter = false; + bool jumpToPrevChapter = false; + @override void initState() { reader = context.reader; @@ -464,6 +473,18 @@ class _ContinuousModeState extends State<_ContinuousMode> } } + void onScroll() { + if (prepareToPrevChapter) { + jumpToNextChapter = false; + jumpToPrevChapter = scrollController.offset < + scrollController.position.minScrollExtent - _kChangeChapterOffset; + } else if (prepareToNextChapter) { + jumpToNextChapter = scrollController.offset > + scrollController.position.maxScrollExtent + _kChangeChapterOffset; + jumpToPrevChapter = false; + } + } + @override Widget build(BuildContext context) { Widget widget = ScrollablePositionedList.builder( @@ -471,7 +492,11 @@ class _ContinuousModeState extends State<_ContinuousMode> itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener, scrollControllerCallback: (scrollController) { - this.scrollController = scrollController; + if (_scrollController != null) { + _scrollController!.removeListener(onScroll); + } + _scrollController = scrollController; + _scrollController!.addListener(onScroll); }, itemCount: reader.maxPage + 2, addSemanticIndexes: false, @@ -481,7 +506,7 @@ class _ContinuousModeState extends State<_ContinuousMode> reverse: reader.mode == ReaderMode.continuousRightToLeft, physics: isCTRLPressed || _isMouseScrolling || disableScroll ? const NeverScrollableScrollPhysics() - : const ClampingScrollPhysics(), + : const BouncingScrollPhysics(), itemBuilder: (context, index) { if (index == 0 || index == reader.maxPage + 1) { return const SizedBox(); @@ -496,18 +521,28 @@ class _ContinuousModeState extends State<_ContinuousMode> ImageProvider image = _createImageProvider(index, context); - return ComicImage( - filterQuality: FilterQuality.medium, - image: image, - width: width, - height: height, - fit: BoxFit.contain, + return ColoredBox( + color: context.colorScheme.surface, + child: ComicImage( + filterQuality: FilterQuality.medium, + image: image, + width: width, + height: height, + fit: BoxFit.contain, + ), ); }, scrollBehavior: const MaterialScrollBehavior() .copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes), ); + widget = Stack( + children: [ + Positioned.fill(child: buildBackground(context)), + Positioned.fill(child: widget), + ], + ); + widget = Listener( onPointerDown: (event) { fingers++; @@ -530,6 +565,13 @@ class _ContinuousModeState extends State<_ContinuousMode> disableScroll = false; }); } + if (fingers == 0) { + if (jumpToPrevChapter) { + reader.toPrevChapter(); + } else if (jumpToNextChapter) { + reader.toNextChapter(); + } + } }, onPointerCancel: (event) { fingers--; @@ -577,15 +619,37 @@ class _ContinuousModeState extends State<_ContinuousMode> if (notification is ScrollUpdateNotification) { if (!scrollController.hasClients) return false; if (scrollController.position.pixels <= - scrollController.position.minScrollExtent && + scrollController.position.minScrollExtent && !reader.isFirstChapterOfGroup) { - context.readerScaffold.setFloatingButton(-1); + if (!prepareToPrevChapter) { + jumpToPrevChapter = false; + jumpToNextChapter = false; + context.readerScaffold.setFloatingButton(-1); + setState(() { + prepareToPrevChapter = true; + }); + } } else if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent && + scrollController.position.maxScrollExtent && !reader.isLastChapterOfGroup) { - context.readerScaffold.setFloatingButton(1); + if (!prepareToNextChapter) { + jumpToPrevChapter = false; + jumpToNextChapter = false; + context.readerScaffold.setFloatingButton(1); + setState(() { + prepareToNextChapter = true; + }); + } } else { context.readerScaffold.setFloatingButton(0); + if (prepareToPrevChapter || prepareToNextChapter) { + jumpToPrevChapter = false; + jumpToNextChapter = false; + setState(() { + prepareToPrevChapter = false; + prepareToNextChapter = false; + }); + } } } @@ -618,6 +682,26 @@ class _ContinuousModeState extends State<_ContinuousMode> ); } + Widget buildBackground(BuildContext context) { + return Column( + children: [ + SizedBox(height: context.padding.top + 16), + if (prepareToPrevChapter) + _SwipeChangeChapterProgress( + controller: scrollController, + isPrev: true, + ), + const Spacer(), + if (prepareToNextChapter) + _SwipeChangeChapterProgress( + controller: scrollController, + isPrev: false, + ), + SizedBox(height: context.padding.bottom + 16), + ], + ); + } + @override Future animateToPage(int page) { return itemScrollController.scrollTo( @@ -758,3 +842,127 @@ void _precacheImage(int page, BuildContext context) { context, ); } + +class _SwipeChangeChapterProgress extends StatefulWidget { + const _SwipeChangeChapterProgress({ + this.controller, + required this.isPrev, + }); + + final ScrollController? controller; + + final bool isPrev; + + @override + State<_SwipeChangeChapterProgress> createState() => + _SwipeChangeChapterProgressState(); +} + +class _SwipeChangeChapterProgressState + extends State<_SwipeChangeChapterProgress> { + double value = 0; + + late final isPrev = widget.isPrev; + + ScrollController? controller; + + @override + void initState() { + super.initState(); + if (widget.controller != null) { + controller = widget.controller; + controller!.addListener(onScroll); + } + } + + @override + void didUpdateWidget(covariant _SwipeChangeChapterProgress oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + controller?.removeListener(onScroll); + controller = widget.controller; + controller?.addListener(onScroll); + if (value != 0) { + setState(() { + value = 0; + }); + } + } + } + + @override + void dispose() { + super.dispose(); + controller?.removeListener(onScroll); + } + + void onScroll() { + var position = controller!.position.pixels; + var offset = isPrev + ? controller!.position.minScrollExtent - position + : position - controller!.position.maxScrollExtent; + var newValue = offset / _kChangeChapterOffset; + newValue = newValue.clamp(0.0, 1.0); + if (newValue != value) { + setState(() { + value = newValue; + }); + } + } + + @override + Widget build(BuildContext context) { + final msg = widget.isPrev + ? "Swipe down for previous chapter".tl + : "Swipe up for next chapter".tl; + + return CustomPaint( + painter: _ProgressPainter( + value: value, + backgroundColor: context.colorScheme.surfaceContainer, + color: context.colorScheme.primaryContainer, + ), + child: Text(msg).paddingVertical(4).paddingHorizontal(16), + ); + } +} + +class _ProgressPainter extends CustomPainter { + final double value; + + final Color backgroundColor; + + final Color color; + + const _ProgressPainter({ + required this.value, + required this.backgroundColor, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.fill; + canvas.drawRRect( + RRect.fromLTRBR(0, 0, size.width, size.height, Radius.circular(16)), + paint, + ); + + paint.color = color; + canvas.drawRRect( + RRect.fromLTRBR( + 0, 0, size.width * value, size.height, Radius.circular(16)), + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return oldDelegate is! _ProgressPainter || + oldDelegate.value != value || + oldDelegate.backgroundColor != backgroundColor || + oldDelegate.color != color; + } +} diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 214dc10..a468bff 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -26,73 +26,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { var lastValue = 0; - var fABValue = ValueNotifier(0); - _ReaderGestureDetectorState? _gestureDetectorState; - _DragListener? _floatingButtonDragListener; - void setFloatingButton(int value) { lastValue = showFloatingButtonValue; if (value == 0) { if (showFloatingButtonValue != 0) { showFloatingButtonValue = 0; - fABValue.value = 0; update(); } - if (_floatingButtonDragListener != null) { - _gestureDetectorState!.removeDragListener(_floatingButtonDragListener!); - _floatingButtonDragListener = null; - } } - var readerMode = context.reader.mode; if (value == 1 && showFloatingButtonValue == 0) { showFloatingButtonValue = 1; - _floatingButtonDragListener = _DragListener( - onMove: (offset) { - if (readerMode == ReaderMode.continuousTopToBottom) { - fABValue.value -= offset.dy; - } else if (readerMode == ReaderMode.continuousLeftToRight) { - fABValue.value -= offset.dx; - } else if (readerMode == ReaderMode.continuousRightToLeft) { - fABValue.value += offset.dx; - } - }, - onEnd: () { - if (fABValue.value.abs() > 58 * 3) { - setState(() { - showFloatingButtonValue = 0; - }); - context.reader.toNextChapter(); - } - fABValue.value = 0; - }, - ); - _gestureDetectorState!.addDragListener(_floatingButtonDragListener!); update(); } else if (value == -1 && showFloatingButtonValue == 0) { showFloatingButtonValue = -1; - _floatingButtonDragListener = _DragListener( - onMove: (offset) { - if (readerMode == ReaderMode.continuousTopToBottom) { - fABValue.value += offset.dy; - } else if (readerMode == ReaderMode.continuousLeftToRight) { - fABValue.value += offset.dx; - } else if (readerMode == ReaderMode.continuousRightToLeft) { - fABValue.value -= offset.dx; - } - }, - onEnd: () { - if (fABValue.value.abs() > 58 * 3) { - setState(() { - showFloatingButtonValue = 0; - }); - context.reader.toPrevChapter(); - } - fABValue.value = 0; - }, - ); - _gestureDetectorState!.addDragListener(_floatingButtonDragListener!); update(); } } @@ -778,62 +726,35 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ); case -1: case 1: - return Container( + return SizedBox( width: 58, height: 58, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( + child: Material( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16), - ), - child: ValueListenableBuilder( - valueListenable: fABValue, - builder: (context, value, child) { - return Stack( - children: [ - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - if (showFloatingButtonValue == 1) { - context.reader.toNextChapter(); - } else if (showFloatingButtonValue == -1) { - context.reader.toPrevChapter(); - } - setFloatingButton(0); - }, - borderRadius: BorderRadius.circular(16), - child: Center( - child: Icon( - showFloatingButtonValue == 1 - ? Icons.arrow_forward_ios - : Icons.arrow_back_ios_outlined, - size: 24, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - height: value.clamp(0, 58 * 3) / 3, - child: ColoredBox( - color: Theme.of(context) - .colorScheme - .surfaceTint - .toOpacity(0.2), - child: const SizedBox.expand(), - ), - ), - ], - ); - }, + elevation: 2, + child: InkWell( + onTap: () { + if (showFloatingButtonValue == 1) { + context.reader.toNextChapter(); + } else if (showFloatingButtonValue == -1) { + context.reader.toPrevChapter(); + } + setFloatingButton(0); + }, + borderRadius: BorderRadius.circular(16), + child: Center( + child: Icon( + showFloatingButtonValue == 1 + ? Icons.arrow_forward_ios + : Icons.arrow_back_ios_outlined, + size: 24, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), ), ); }