mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 17:49:05 +03:00
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:
@@ -2026,6 +2026,9 @@
|
|||||||
"set_profile_picture": "Set profile picture",
|
"set_profile_picture": "Set profile picture",
|
||||||
"set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
|
"set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
|
||||||
"set_stack_primary_asset": "Set as primary asset",
|
"set_stack_primary_asset": "Set as primary asset",
|
||||||
|
"setting_image_navigation_enable_subtitle": "If enabled, you can navigate to the previous/next image by tapping the leftmost/rightmost quarter of the screen.",
|
||||||
|
"setting_image_navigation_enable_title": "Tap to Navigate",
|
||||||
|
"setting_image_navigation_title": "Image Navigation",
|
||||||
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
||||||
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
|
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
|
||||||
"setting_image_viewer_original_title": "Load original image",
|
"setting_image_viewer_original_title": "Load original image",
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ enum StoreKey<T> {
|
|||||||
autoPlayVideo<bool>._(139),
|
autoPlayVideo<bool>._(139),
|
||||||
albumGridView<bool>._(140),
|
albumGridView<bool>._(140),
|
||||||
|
|
||||||
|
// Image viewer navigation settings
|
||||||
|
tapToNavigate<bool>._(141),
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000),
|
photoManagerCustomFilter<bool>._(1000),
|
||||||
betaPromptShown<bool>._(1001),
|
betaPromptShown<bool>._(1001),
|
||||||
|
|||||||
@@ -221,8 +221,37 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
onDragUpdate: (_, details, __) {
|
onDragUpdate: (_, details, __) {
|
||||||
handleSwipeUpDown(details);
|
handleSwipeUpDown(details);
|
||||||
},
|
},
|
||||||
onTapDown: (_, __, ___) {
|
onTapDown: (ctx, tapDownDetails, _) {
|
||||||
ref.read(showControlsProvider.notifier).toggle();
|
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
|
onLongPressStart: asset.isMotionPhoto
|
||||||
? (_, __, ___) {
|
? (_, __, ___) {
|
||||||
|
|||||||
@@ -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/asset_viewer/video_viewer.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.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/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/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_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.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
@@ -29,8 +31,9 @@ enum _DragIntent { none, scroll, dismiss }
|
|||||||
class AssetPage extends ConsumerStatefulWidget {
|
class AssetPage extends ConsumerStatefulWidget {
|
||||||
final int index;
|
final int index;
|
||||||
final int heroOffset;
|
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
|
@override
|
||||||
ConsumerState createState() => _AssetPageState();
|
ConsumerState createState() => _AssetPageState();
|
||||||
@@ -224,7 +227,28 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
|
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) =>
|
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
bool _assetReloadRequested = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -270,7 +280,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
: const FastClampingScrollPhysics(),
|
: const FastClampingScrollPhysics(),
|
||||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||||
onPageChanged: (index) => _onAssetChanged(index),
|
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)
|
if (!CurrentPlatform.isIOS)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ enum AppSettingsEnum<T> {
|
|||||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
|
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
|
||||||
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
|
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
|
||||||
|
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
|
||||||
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
|
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
|
||||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||||
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
|
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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_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 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||||
import 'video_viewer_settings.dart';
|
import 'video_viewer_settings.dart';
|
||||||
|
|
||||||
@@ -8,7 +9,11 @@ class AssetViewerSettings extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final assetViewerSetting = [const ImageViewerQualitySetting(), const VideoViewerSettings()];
|
final assetViewerSetting = [
|
||||||
|
const ImageViewerQualitySetting(),
|
||||||
|
const ImageViewerTapToNavigateSetting(),
|
||||||
|
const VideoViewerSettings(),
|
||||||
|
];
|
||||||
|
|
||||||
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
|
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -227,10 +227,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,10 +328,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1217,10 +1217,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1910,10 +1910,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
thumbhash:
|
thumbhash:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user