feat: tap to see next/previous image (#20286)

* feat(mobile): tap behavior for next/previous image

This change enables switching to the next/previous photo in the photo
viewer by tapping the left/right quarter of the screen.

* Avoid animation on first/last image

* Add changes to asset_viewer.page

* Add setting for tap navigation, disable by default

Not everyone wants to have tapping for next/previous image enabled, so
this commit adds a settings toggle. Since it might be confusing behavior
for new users, it is disabled by default.

* chore: refactor

* fix: lint

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Alex Balgavy
2026-02-22 06:28:17 +01:00
committed by GitHub
parent 1d25267f22
commit 8ba20cbd44
10 changed files with 120 additions and 14 deletions

View File

@@ -73,6 +73,9 @@ enum StoreKey<T> {
autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
// Image viewer navigation settings
tapToNavigate<bool>._(141),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),

View File

@@ -221,8 +221,37 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) {
handleSwipeUpDown(details);
},
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
onTapDown: (ctx, tapDownDetails, _) {
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
ref.read(showControlsProvider.notifier).toggle();
return;
}
double tapX = tapDownDetails.globalPosition.dx;
double screenWidth = ctx.width;
// We want to change images if the user taps in the leftmost or
// rightmost quarter of the screen
bool tappedLeftSide = tapX < screenWidth / 4;
bool tappedRightSide = tapX > screenWidth * (3 / 4);
int? currentPage = controller.page?.toInt();
int maxPage = renderList.totalAssets - 1;
if (tappedLeftSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != 0) {
controller.jumpToPage(currentPage - 1);
}
} else if (tappedRightSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != maxPage) {
controller.jumpToPage(currentPage + 1);
}
} else {
ref.read(showControlsProvider.notifier).toggle();
}
},
onLongPressStart: asset.isMotionPhoto
? (_, __, ___) {

View File

@@ -16,8 +16,10 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -29,8 +31,9 @@ enum _DragIntent { none, scroll, dismiss }
class AssetPage extends ConsumerStatefulWidget {
final int index;
final int heroOffset;
final void Function(int direction)? onTapNavigate;
const AssetPage({super.key, required this.index, required this.heroOffset});
const AssetPage({super.key, required this.index, required this.heroOffset, this.onTapNavigate});
@override
ConsumerState createState() => _AssetPageState();
@@ -224,7 +227,28 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
if (!_showingDetails && _dragStart == null) _viewer.toggleControls();
if (_showingDetails || _dragStart != null) return;
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
_viewer.toggleControls();
return;
}
final tapX = details.globalPosition.dx;
final screenWidth = context.width;
// Navigate if the user taps in the leftmost or rightmost quarter of the screen
final tappedLeftSide = tapX < screenWidth / 4;
final tappedRightSide = tapX > screenWidth * (3 / 4);
if (tappedLeftSide) {
widget.onTapNavigate?.call(-1);
} else if (tappedRightSide) {
widget.onTapNavigate?.call(1);
} else {
_viewer.toggleControls();
}
}
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>

View File

@@ -96,6 +96,16 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) return;
final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_pageController.jumpToPage(target);
}
}
@override
void initState() {
super.initState();
@@ -270,7 +280,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
: const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets,
onPageChanged: (index) => _onAssetChanged(index),
itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset),
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
if (!CurrentPlatform.isIOS)

View File

@@ -35,6 +35,7 @@ enum AppSettingsEnum<T> {
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'video_viewer_settings.dart';
@@ -8,7 +9,11 @@ class AssetViewerSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final assetViewerSetting = [const ImageViewerQualitySetting(), const VideoViewerSettings()];
final assetViewerSetting = [
const ImageViewerQualitySetting(),
const ImageViewerTapToNavigateSetting(),
const VideoViewerSettings(),
];
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
}

View File

@@ -0,0 +1,30 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
const ImageViewerTapToNavigateSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "setting_image_navigation_title".tr()),
SettingsSwitchListTile(
valueNotifier: tapToNavigate,
title: "setting_image_navigation_enable_title".tr(),
subtitle: "setting_image_navigation_enable_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
}
}