1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile): drag to select assets (#8004)

fear(mobile): drag to select assets

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2024-03-19 14:31:56 +00:00 committed by GitHub
parent 9274c0701b
commit 9e4bab7494
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 420 additions and 21 deletions

View File

@ -0,0 +1,223 @@
// ignore_for_file: library_private_types_in_public_api
// Based on https://stackoverflow.com/a/52625182
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class AssetDragRegion extends StatefulWidget {
final Widget child;
final void Function(AssetIndex valueKey)? onStart;
final void Function(AssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const AssetDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _AssetDragRegionState();
}
class _AssetDragRegionState extends State<AssetDragRegion> {
late AssetIndex? assetUnderPointer;
late AssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
recognizer.onLongPressCancel = _onLongPressEnd;
}
AssetIndex? _getValueKeyAtPositon(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path
.firstWhereOrNull((hit) => hit.target is _AssetIndexProxy)
?.target as _AssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null &&
(topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPositon(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
// ignore: prefer-single-widget-per-file
class AssetIndexWrapper extends SingleChildRenderObjectWidget {
final int rowIndex;
final int sectionIndex;
const AssetIndexWrapper({
required Widget super.child,
required this.rowIndex,
required this.sectionIndex,
super.key,
});
@override
_AssetIndexProxy createRenderObject(BuildContext context) {
return _AssetIndexProxy(
index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
_AssetIndexProxy renderObject,
) {
renderObject.index =
AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex);
}
}
class _AssetIndexProxy extends RenderProxyBox {
AssetIndex index;
_AssetIndexProxy({
required this.index,
});
}
class AssetIndex {
final int rowIndex;
final int sectionIndex;
const AssetIndex({
required this.rowIndex,
required this.sectionIndex,
});
@override
bool operator ==(covariant AssetIndex other) {
if (identical(this, other)) return true;
return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex;
}
@override
int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode;
}

View File

@ -5,12 +5,15 @@ import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget {
class ImmichAssetGridViewState extends State<ImmichAssetGridView> { class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final ItemScrollController _itemScrollController = ItemScrollController(); final ItemScrollController _itemScrollController = ItemScrollController();
final ScrollOffsetController _scrollOffsetController =
ScrollOffsetController();
final ItemPositionsListener _itemPositionsListener = final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create(); ItemPositionsListener.create();
@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final Set<Asset> _selectedAssets = final Set<Asset> _selectedAssets =
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
bool _dragging = false;
int? _dragAnchorAssetIndex;
int? _dragAnchorSectionIndex;
final Set<Asset> _draggedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets); return Set.from(_selectedAssets);
} }
@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _selectAssets(List<Asset> assets) { void _selectAssets(List<Asset> assets) {
setState(() { setState(() {
if (_dragging) {
_draggedAssets.addAll(assets);
}
_selectedAssets.addAll(assets); _selectedAssets.addAll(assets);
_callSelectionListener(true); _callSelectionListener(true);
}); });
} }
void _deselectAssets(List<Asset> assets) { void _deselectAssets(List<Asset> assets) {
final assetsToDeselect = assets.where(
(a) =>
widget.canDeselect ||
!(widget.preselectedAssets?.contains(a) ?? false),
);
setState(() { setState(() {
_selectedAssets.removeAll( _selectedAssets.removeAll(assetsToDeselect);
assets.where( if (_dragging) {
(a) => _draggedAssets.removeAll(assetsToDeselect);
widget.canDeselect || }
!(widget.preselectedAssets?.contains(a) ?? false),
),
);
_callSelectionListener(_selectedAssets.isNotEmpty); _callSelectionListener(_selectedAssets.isNotEmpty);
}); });
} }
@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAll() { void _deselectAll() {
setState(() { setState(() {
_selectedAssets.clear(); _selectedAssets.clear();
_dragAnchorAssetIndex = null;
_dragAnchorSectionIndex = null;
_draggedAssets.clear();
_dragging = false;
if (!widget.canDeselect && if (!widget.canDeselect &&
widget.preselectedAssets != null && widget.preselectedAssets != null &&
widget.preselectedAssets!.isNotEmpty) { widget.preselectedAssets!.isNotEmpty) {
@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
showStorageIndicator: widget.showStorageIndicator, showStorageIndicator: widget.showStorageIndicator,
selectedAssets: _selectedAssets, selectedAssets: _selectedAssets,
selectionActive: widget.selectionActive, selectionActive: widget.selectionActive,
sectionIndex: index,
section: section, section: section,
margin: widget.margin, margin: widget.margin,
renderList: widget.renderList, renderList: widget.renderList,
@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
itemBuilder: _itemBuilder, itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
scrollOffsetController: _scrollOffsetController,
itemCount: widget.renderList.elements.length + itemCount: widget.renderList.elements.length +
(widget.topWidget != null ? 1 : 0), (widget.topWidget != null ? 1 : 0),
addRepaintBoundaries: true, addRepaintBoundaries: true,
@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
if (widget.visibleItemsListener != null) { if (widget.visibleItemsListener != null) {
_itemPositionsListener.itemPositions.removeListener(_positionListener); _itemPositionsListener.itemPositions.removeListener(_positionListener);
} }
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
super.dispose(); super.dispose();
} }
@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
); );
} }
void _setDragStartIndex(AssetIndex index) {
setState(() {
_dragAnchorAssetIndex = index.rowIndex;
_dragAnchorSectionIndex = index.sectionIndex;
_dragging = true;
});
}
void _stopDrag() {
setState(() {
_dragging = false;
_draggedAssets.clear();
});
}
void _dragDragScroll(ScrollDirection direction) {
_scrollOffsetController.animateScroll(
offset: direction == ScrollDirection.forward ? 175 : -175,
duration: const Duration(milliseconds: 125),
);
}
void _handleDragAssetEnter(AssetIndex index) {
if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) {
return;
}
final dragAnchorSectionIndex = _dragAnchorSectionIndex!;
final dragAnchorAssetIndex = _dragAnchorAssetIndex!;
late final int startSectionIndex;
late final int startSectionAssetIndex;
late final int endSectionIndex;
late final int endSectionAssetIndex;
if (index.sectionIndex < dragAnchorSectionIndex) {
startSectionIndex = index.sectionIndex;
startSectionAssetIndex = index.rowIndex;
endSectionIndex = dragAnchorSectionIndex;
endSectionAssetIndex = dragAnchorAssetIndex;
} else if (index.sectionIndex > dragAnchorSectionIndex) {
startSectionIndex = dragAnchorSectionIndex;
startSectionAssetIndex = dragAnchorAssetIndex;
endSectionIndex = index.sectionIndex;
endSectionAssetIndex = index.rowIndex;
} else {
startSectionIndex = dragAnchorSectionIndex;
endSectionIndex = dragAnchorSectionIndex;
// If same section, assign proper start / end asset Index
if (dragAnchorAssetIndex < index.rowIndex) {
startSectionAssetIndex = dragAnchorAssetIndex;
endSectionAssetIndex = index.rowIndex;
} else {
startSectionAssetIndex = index.rowIndex;
endSectionAssetIndex = dragAnchorAssetIndex;
}
}
final selectedAssets = <Asset>{};
var currentSectionIndex = startSectionIndex;
while (currentSectionIndex < endSectionIndex) {
final section =
widget.renderList.elements.elementAtOrNull(currentSectionIndex);
if (section == null) continue;
final sectionAssets =
widget.renderList.loadAssets(section.offset, section.count);
if (currentSectionIndex == startSectionIndex) {
selectedAssets.addAll(
sectionAssets.slice(startSectionAssetIndex, sectionAssets.length),
);
} else {
selectedAssets.addAll(sectionAssets);
}
currentSectionIndex += 1;
}
final section = widget.renderList.elements.elementAtOrNull(endSectionIndex);
if (section != null) {
final sectionAssets =
widget.renderList.loadAssets(section.offset, section.count);
if (startSectionIndex == endSectionIndex) {
selectedAssets.addAll(
sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1),
);
} else {
selectedAssets.addAll(
sectionAssets.slice(0, endSectionAssetIndex + 1),
);
}
}
_deselectAssets(_draggedAssets.toList());
_draggedAssets.clear();
_draggedAssets.addAll(selectedAssets);
_selectAssets(_draggedAssets.toList());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopScope( return PopScope(
@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, onPopInvoked: (didPop) => !didPop ? _deselectAll() : null,
child: Stack( child: Stack(
children: [ children: [
_buildAssetGrid(), AssetDragRegion(
onStart: _setDragStartIndex,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragDragScroll,
onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback(
(_) => controlBottomAppBarNotifier.minimize(),
),
child: _buildAssetGrid(),
),
if (widget.showMultiSelectIndicator && widget.selectionActive) if (widget.showMultiSelectIndicator && widget.selectionActive)
_buildMultiSelectIndicator(), _buildMultiSelectIndicator(),
], ],
@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget {
/// A section for the render grid /// A section for the render grid
class _Section extends StatelessWidget { class _Section extends StatelessWidget {
final RenderAssetGridElement section; final RenderAssetGridElement section;
final int sectionIndex;
final Set<Asset> selectedAssets; final Set<Asset> selectedAssets;
final bool scrolling; final bool scrolling;
final double margin; final double margin;
@ -377,6 +512,7 @@ class _Section extends StatelessWidget {
const _Section({ const _Section({
required this.section, required this.section,
required this.sectionIndex,
required this.scrolling, required this.scrolling,
required this.margin, required this.margin,
required this.assetsPerRow, required this.assetsPerRow,
@ -435,6 +571,8 @@ class _Section extends StatelessWidget {
) )
: _AssetRow( : _AssetRow(
key: ValueKey(i), key: ValueKey(i),
rowStartIndex: i * assetsPerRow,
sectionIndex: sectionIndex,
assets: assetsToRender.nestedSlice( assets: assetsToRender.nestedSlice(
i * assetsPerRow, i * assetsPerRow,
min((i + 1) * assetsPerRow, section.count), min((i + 1) * assetsPerRow, section.count),
@ -522,6 +660,8 @@ class _Title extends StatelessWidget {
/// The row of assets /// The row of assets
class _AssetRow extends StatelessWidget { class _AssetRow extends StatelessWidget {
final List<Asset> assets; final List<Asset> assets;
final int rowStartIndex;
final int sectionIndex;
final Set<Asset> selectedAssets; final Set<Asset> selectedAssets;
final int absoluteOffset; final int absoluteOffset;
final double width; final double width;
@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget {
const _AssetRow({ const _AssetRow({
super.key, super.key,
required this.rowStartIndex,
required this.sectionIndex,
required this.assets, required this.assets,
required this.absoluteOffset, required this.absoluteOffset,
required this.width, required this.width,
@ -594,18 +736,22 @@ class _AssetRow extends StatelessWidget {
bottom: margin, bottom: margin,
right: last ? 0.0 : margin, right: last ? 0.0 : margin,
), ),
child: ThumbnailImage( child: AssetIndexWrapper(
asset: asset, rowIndex: rowStartIndex + index,
index: absoluteOffset + index, sectionIndex: sectionIndex,
loadAsset: renderList.loadAsset, child: ThumbnailImage(
totalAssets: renderList.totalAssets, asset: asset,
multiselectEnabled: selectionActive, index: absoluteOffset + index,
isSelected: isSelectionActive && selectedAssets.contains(asset), loadAsset: renderList.loadAsset,
onSelect: () => onSelect?.call(asset), totalAssets: renderList.totalAssets,
onDeselect: () => onDeselect?.call(asset), multiselectEnabled: selectionActive,
showStorageIndicator: showStorageIndicator, isSelected: isSelectionActive && selectedAssets.contains(asset),
heroOffset: heroOffset, onSelect: () => onSelect?.call(asset),
showStack: showStack, onDeselect: () => onDeselect?.call(asset),
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
showStack: showStack,
),
), ),
); );
}).toList(), }).toList(),

View File

@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
class ControlBottomAppBar extends ConsumerWidget { final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
class ControlBottomAppBarNotifier with ChangeNotifier {
void minimize() {
notifyListeners();
}
}
class ControlBottomAppBar extends HookConsumerWidget {
final void Function(bool shareLocal) onShare; final void Function(bool shareLocal) onShare;
final void Function()? onFavorite; final void Function()? onFavorite;
final void Function()? onArchive; final void Function()? onArchive;
@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget {
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider); final sharedAlbums = ref.watch(sharedAlbumProvider);
const bottomPadding = 0.20; const bottomPadding = 0.20;
final scrollController = useDraggableScrollController();
void minimize() {
scrollController.animateTo(
bottomPadding,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
useEffect(
() {
controlBottomAppBarNotifier.addListener(minimize);
return () {
controlBottomAppBarNotifier.removeListener(minimize);
};
},
[],
);
void showForceDeleteDialog( void showForceDeleteDialog(
Function(bool) deleteCb, { Function(bool) deleteCb, {
@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget {
} }
return DraggableScrollableSheet( return DraggableScrollableSheet(
controller: scrollController,
initialChildSize: hasRemote ? 0.35 : bottomPadding, initialChildSize: hasRemote ? 0.35 : bottomPadding,
minChildSize: bottomPadding, minChildSize: bottomPadding,
maxChildSize: hasRemote ? 0.65 : bottomPadding, maxChildSize: hasRemote ? 0.65 : bottomPadding,