Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';

import '../../../../../constants/enum.dart';
import '../../../../../routes/router_config.dart';
import '../../../../../widgets/zoom/single_touch_drag_recognizers.dart';
import '../../../domain/chapter/chapter_model.dart';
import '../../../domain/chapter_page/chapter_page_model.dart';
import '../utils/last_page_swipe_utils.dart';
Expand Down Expand Up @@ -57,60 +58,90 @@ class DirectionalSwipeGestureHandler extends HookWidget {
Widget build(BuildContext context) {
final bool useAdvancedGestures =
lastPageSwipeEnabled && !readerSwipeChapterToggle;

if (useAdvancedGestures) {
return _buildAdvancedGestureHandler(context);
} else {
return _buildSimpleGestureHandler(context);
}
return useAdvancedGestures
? _buildAdvancedGestureHandler(context)
: _buildSimpleGestureHandler(context);
}

/// Advanced gesture handler using RawGestureDetector for proper arena competition
/// Advanced gesture handler — single-touch pan recognizer for swipe-at-
/// last-page; tap + long-press in a nested GestureDetector (those don't
/// fight multi-touch).
Widget _buildAdvancedGestureHandler(BuildContext context) {
return GestureDetector(
onLongPressStart: onLongPressStart,
onLongPressEnd: onLongPressEnd,
onLongPressMoveUpdate: onLongPressMoveUpdate,
onTap: onTap,
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
onPanEnd: (details) {
final swipeDirection = LastPageSwipeUtils.detectSwipeDirection(details);

if (swipeDirection != null) {
_handleAdvancedSwipeGesture(
context: context,
direction: swipeDirection,
details: details,
);
}
gestures: <Type, GestureRecognizerFactory>{
SingleTouchPanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<SingleTouchPanGestureRecognizer>(
() => SingleTouchPanGestureRecognizer(debugOwner: this),
(recognizer) {
recognizer.onEnd = (details) {
final swipeDirection =
LastPageSwipeUtils.detectSwipeDirection(details);
if (swipeDirection != null) {
_handleAdvancedSwipeGesture(
context: context,
direction: swipeDirection,
details: details,
);
}
};
},
),
},
child: child,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: onLongPressStart,
onLongPressEnd: onLongPressEnd,
onLongPressMoveUpdate: onLongPressMoveUpdate,
onTap: onTap,
child: child,
),
);
}

/// Simple gesture handler as fallback
/// Simple gesture handler — single-touch horizontal + vertical drag
/// recognizers for swipe-at-chapter-boundary; tap + long-press nested.
Widget _buildSimpleGestureHandler(BuildContext context) {
return GestureDetector(
onLongPressStart: onLongPressStart,
onLongPressEnd: onLongPressEnd,
onLongPressMoveUpdate: onLongPressMoveUpdate,
onTap: onTap,
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragEnd: (details) {
_handleSwipeGesture(
context: context,
details: details,
allowedAxis: Axis.vertical,
);
},
onVerticalDragEnd: (details) {
_handleSwipeGesture(
context: context,
details: details,
allowedAxis: Axis.horizontal,
);
gestures: <Type, GestureRecognizerFactory>{
SingleTouchHorizontalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
SingleTouchHorizontalDragGestureRecognizer>(
() => SingleTouchHorizontalDragGestureRecognizer(debugOwner: this),
(recognizer) {
recognizer.onEnd = (details) {
_handleSwipeGesture(
context: context,
details: details,
allowedAxis: Axis.vertical,
);
};
},
),
SingleTouchVerticalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
SingleTouchVerticalDragGestureRecognizer>(
() => SingleTouchVerticalDragGestureRecognizer(debugOwner: this),
(recognizer) {
recognizer.onEnd = (details) {
_handleSwipeGesture(
context: context,
details: details,
allowedAxis: Axis.horizontal,
);
};
},
),
},
child: child,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: onLongPressStart,
onLongPressEnd: onLongPressEnd,
onLongPressMoveUpdate: onLongPressMoveUpdate,
onTap: onTap,
child: child,
),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:zoom_view/zoom_view.dart';

import '../../../../../../utils/extensions/custom_extensions.dart';
import '../../../../../../utils/misc/app_utils.dart';
import '../../../../../../widgets/server_image.dart';
import '../../../../../../widgets/zoom/scroll_offset_to_scroll_controller.dart';
import '../../../../../settings/presentation/reader/widgets/reader_pinch_to_zoom/reader_pinch_to_zoom.dart';
import '../../../../../settings/presentation/reader/widgets/reader_scroll_animation_tile/reader_scroll_animation_tile.dart';
import '../../../../domain/chapter/chapter_model.dart';
Expand Down Expand Up @@ -65,6 +67,14 @@ class ContinuousReaderMode extends HookConsumerWidget {
useMemoized(() => ItemScrollController());
final ItemPositionsListener positionsListener =
useMemoized(() => ItemPositionsListener.create());
final ScrollOffsetController scrollOffsetController =
useMemoized(() => ScrollOffsetController());
final ScrollController zoomScrollController = useMemoized(
() => ScrollOffsetToScrollController(
scrollOffsetController: scrollOffsetController,
),
[scrollOffsetController],
);

final ValueNotifier<int> currentIndex = useState(
chapter.isRead.ifNull()
Expand Down Expand Up @@ -184,11 +194,22 @@ class ContinuousReaderMode extends HookConsumerWidget {
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
isPinchToZoomEnabled
? (Widget child) => InteractiveViewer(maxScale: 5, child: child)
? (Widget child) => ZoomView(
controller: zoomScrollController,
scrollAxis: scrollDirection,
maxScale: 5,
doubleTapDrag: true,
// Required so the scale recognizer wins the gesture
// arena against the underlying scrollable's drag
// recognizer (closes #256).
forceHoldOnPointerDown: true,
child: child,
)
: null,
ScrollablePositionedList.separated(
itemScrollController: scrollController,
itemPositionsListener: positionsListener,
scrollOffsetController: scrollOffsetController,
initialScrollIndex: chapter.isRead.ifNull()
? 0
: chapter.lastPageRead.getValueOnNullOrNegative(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zoom_view/zoom_view.dart';

import '../../../../../../constants/app_constants.dart';
import '../../../../../../utils/extensions/cache_manager_extensions.dart';
Expand Down Expand Up @@ -110,47 +111,56 @@ class SinglePageReaderMode extends HookConsumerWidget {
curve: kCurve,
),
pageController: scrollController,
child: PageView.builder(
scrollDirection: scrollDirection,
reverse: reverse,
controller: scrollController,
allowImplicitScrolling: true,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
itemBuilder: (BuildContext context, int index) {
// Show loading indicator if no pages are available yet
if (chapterPages.pages.isEmpty) {
return const Center(
child: CenterSorayomiShimmerIndicator(),
);
}
child: AppUtils.wrapOn(
!kIsWeb && (Platform.isAndroid || Platform.isIOS)
? (Widget child) => ZoomView(
controller: scrollController,
scrollAxis: scrollDirection,
maxScale: 5,
doubleTapDrag: true,
// Required so the scale recognizer wins the gesture
// arena against the underlying PageView's pan
// recognizer (closes #256).
forceHoldOnPointerDown: true,
child: child,
)
: null,
PageView.builder(
scrollDirection: scrollDirection,
reverse: reverse,
controller: scrollController,
allowImplicitScrolling: true,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
itemBuilder: (BuildContext context, int index) {
// Show loading indicator if no pages are available yet
if (chapterPages.pages.isEmpty) {
return const Center(
child: CenterSorayomiShimmerIndicator(),
);
}

// Add bounds checking to prevent accessing non-existent pages
if (index >= chapterPages.pages.length) {
return const Center(
child: CenterSorayomiShimmerIndicator(),
);
}
// Add bounds checking to prevent accessing non-existent pages
if (index >= chapterPages.pages.length) {
return const Center(
child: CenterSorayomiShimmerIndicator(),
);
}

final image = ServerImage(
showReloadButton: true,
fit: BoxFit.contain,
size: Size.fromHeight(context.height),
appendApiToUrl: false,
imageUrl: chapterPages.pages[index],
progressIndicatorBuilder: (context, url, downloadProgress) =>
CenterSorayomiShimmerIndicator(
value: downloadProgress.progress,
),
);
return AppUtils.wrapOn(
!kIsWeb && (Platform.isAndroid || Platform.isIOS)
? (child) => InteractiveViewer(maxScale: 5, child: child)
: null,
image,
);
},
itemCount: chapterPages.pages.isEmpty ? 1 : chapterPages.pages.length,
return ServerImage(
showReloadButton: true,
fit: BoxFit.contain,
size: Size.fromHeight(context.height),
appendApiToUrl: false,
imageUrl: chapterPages.pages[index],
progressIndicatorBuilder: (context, url, downloadProgress) =>
CenterSorayomiShimmerIndicator(
value: downloadProgress.progress,
),
);
},
itemCount: chapterPages.pages.isEmpty ? 1 : chapterPages.pages.length,
),
),
);
}
Expand Down
50 changes: 50 additions & 0 deletions lib/src/widgets/zoom/scroll_offset_to_scroll_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2022 Contributors to the Suwayomi project
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

// Adapter that exposes a `ScrollablePositionedList.scrollOffsetController` as
// a standard `ScrollController`. Required by `zoom_view`, which needs a
// `ScrollController` to coordinate scroll position with pinch-zoom but
// `ScrollablePositionedList` only exposes `ScrollOffsetController`.
//
// Depends on the `position` getter that yakagami's fork of
// scrollable_positioned_list adds to `ScrollOffsetController` — see the
// pubspec comment on that dependency.

import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';

class ScrollOffsetToScrollController extends ScrollController {
ScrollOffsetToScrollController({required this.scrollOffsetController});

final ScrollOffsetController scrollOffsetController;

@override
ScrollPosition get position => scrollOffsetController.position;

@override
void jumpTo(double value) {
// ScrollOffsetController doesn't expose a public jumpTo; go through the
// underlying ScrollPosition directly (which the fork makes available).
scrollOffsetController.position.jumpTo(value);
}

@override
Future<void> animateTo(
double offset, {
required Curve curve,
required Duration duration,
}) {
// ScrollOffsetController.animateScroll takes a RELATIVE offset (from
// current pixels); translate the absolute target ScrollController users
// pass in.
final delta = offset - scrollOffsetController.position.pixels;
return scrollOffsetController.animateScroll(
offset: delta,
duration: duration,
curve: curve,
);
}
}
Loading