Drag photos to rearrange them in album

This commit is contained in:
Ming Ming 2021-07-08 16:57:20 +08:00
parent 7644a23f44
commit 8de5597575
5 changed files with 390 additions and 26 deletions

View file

@ -547,6 +547,10 @@
"@sortOptionTimeDescendingLabel": { "@sortOptionTimeDescendingLabel": {
"description": "Sort by time, in descending order" "description": "Sort by time, in descending order"
}, },
"albumEditDragRearrangeNotification": "Long press and drag an item to rearrange it manually",
"@albumEditDragRearrangeNotification": {
"description": "Instructions on how to rearrange photos"
},
"changelogTitle": "Changelog", "changelogTitle": "Changelog",
"@changelogTitle": { "@changelogTitle": {

View file

@ -9,5 +9,8 @@ class SessionStorage {
/// Whether the range select notification has been shown to user /// Whether the range select notification has been shown to user
bool hasShowRangeSelectNotification = false; bool hasShowRangeSelectNotification = false;
/// Whether the drag to rearrange notification has been shown
bool hasShowDragRearrangeNotification = false;
static SessionStorage _inst = SessionStorage._(); static SessionStorage _inst = SessionStorage._();
} }

View file

@ -15,11 +15,13 @@ import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/list_extension.dart'; import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/session_storage.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/resync_album.dart'; import 'package:nc_photos/use_case/resync_album.dart';
import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_album.dart';
import 'package:nc_photos/widget/album_viewer_mixin.dart'; import 'package:nc_photos/widget/album_viewer_mixin.dart';
import 'package:nc_photos/widget/draggable_item_list_mixin.dart';
import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
import 'package:nc_photos/widget/viewer.dart'; import 'package:nc_photos/widget/viewer.dart';
@ -59,6 +61,7 @@ class _AlbumViewerState extends State<AlbumViewer>
with with
WidgetsBindingObserver, WidgetsBindingObserver,
SelectableItemStreamListMixin<AlbumViewer>, SelectableItemStreamListMixin<AlbumViewer>,
DraggableItemListMixin<AlbumViewer>,
AlbumViewerMixin<AlbumViewer> { AlbumViewerMixin<AlbumViewer> {
@override @override
initState() { initState() {
@ -90,6 +93,15 @@ class _AlbumViewerState extends State<AlbumViewer>
enterEditMode() { enterEditMode() {
super.enterEditMode(); super.enterEditMode();
_editAlbum = _album.copyWith(); _editAlbum = _album.copyWith();
if (!SessionStorage().hasShowDragRearrangeNotification) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context).albumEditDragRearrangeNotification),
duration: k.snackBarDurationNormal,
));
SessionStorage().hasShowDragRearrangeNotification = true;
}
} }
@override @override
@ -158,29 +170,39 @@ class _AlbumViewerState extends State<AlbumViewer>
], ],
); );
} }
Widget content = CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 8),
sliver: isEditMode
? buildDraggableItemList(
maxCrossAxisExtent: thumbSize.toDouble(),
onMaxExtentChanged: (value) {
_itemListMaxExtent = value;
},
)
: buildItemStreamList(
maxCrossAxisExtent: thumbSize.toDouble(),
),
),
],
);
if (isEditMode) {
content = Listener(
onPointerMove: _onEditModePointerMove,
child: content,
);
}
return buildItemStreamListOuter( return buildItemStreamListOuter(
context, context,
child: Theme( child: Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context), accentColor: AppTheme.getOverscrollIndicatorColor(context),
), ),
child: CustomScrollView( child: content,
slivers: [
_buildAppBar(context),
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 8),
sliver: SliverIgnorePointer(
ignoring: isEditMode,
sliver: SliverOpacity(
opacity: isEditMode ? .25 : 1,
sliver: buildItemStreamList(
maxCrossAxisExtent: thumbSize.toDouble(),
),
),
),
),
],
),
), ),
); );
} }
@ -342,21 +364,82 @@ class _AlbumViewerState extends State<AlbumViewer>
}); });
} }
void _onEditModePointerMove(PointerMoveEvent event) {
assert(isEditMode);
if (!_isDragging) {
return;
}
if (event.position.dy >= MediaQuery.of(context).size.height - 100) {
// near bottom of screen
if (_isDragScrollingDown == true) {
return;
}
final maxExtent =
_itemListMaxExtent ?? _scrollController.position.maxScrollExtent;
_log.fine("[_onEditModePointerMove] Begin scrolling down");
if (_scrollController.offset <
_scrollController.position.maxScrollExtent) {
_scrollController.animateTo(maxExtent,
duration: Duration(
milliseconds:
((maxExtent - _scrollController.offset) * 1.6).round()),
curve: Curves.linear);
_isDragScrollingDown = true;
}
} else if (event.position.dy <= 100) {
// near top of screen
if (_isDragScrollingDown == false) {
return;
}
_log.fine("[_onEditModePointerMove] Begin scrolling up");
if (_scrollController.offset > 0) {
_scrollController.animateTo(0,
duration: Duration(
milliseconds: (_scrollController.offset * 1.6).round()),
curve: Curves.linear);
_isDragScrollingDown = false;
}
} else if (_isDragScrollingDown != null) {
_log.fine("[_onEditModePointerMove] Stop scrolling");
_scrollController.jumpTo(_scrollController.offset);
_isDragScrollingDown = null;
}
}
void _onItemMoved(int fromIndex, int toIndex, bool isBefore) {
if (fromIndex == toIndex) {
return;
}
final item = _sortedItems.removeAt(fromIndex);
final newIndex =
toIndex + (isBefore ? 0 : 1) + (fromIndex < toIndex ? -1 : 0);
_sortedItems.insert(newIndex, item);
_editAlbum = _editAlbum.copyWith(
sortProvider: AlbumNullSortProvider(),
// save the current order
provider: AlbumStaticProvider(
items: _sortedItems,
),
);
setState(() {
_transformItems();
});
}
void _transformItems() { void _transformItems() {
List<AlbumItem> sortedItems;
if (_editAlbum != null) { if (_editAlbum != null) {
// edit mode // edit mode
sortedItems = _editAlbum.sortProvider.sort(_getAlbumItemsOf(_editAlbum)); _sortedItems = _editAlbum.sortProvider.sort(_getAlbumItemsOf(_editAlbum));
} else { } else {
sortedItems = _album.sortProvider.sort(_getAlbumItemsOf(_album)); _sortedItems = _album.sortProvider.sort(_getAlbumItemsOf(_album));
} }
_backingFiles = sortedItems _backingFiles = _sortedItems
.whereType<AlbumFileItem>() .whereType<AlbumFileItem>()
.map((e) => e.file) .map((e) => e.file)
.where((element) => file_util.isSupportedFormat(element)) .where((element) => file_util.isSupportedFormat(element))
.toList(); .toList();
itemStreamListItems = () sync* { final items = () sync* {
for (int i = 0; i < _backingFiles.length; ++i) { for (int i = 0; i < _backingFiles.length; ++i) {
final f = _backingFiles[i]; final f = _backingFiles[i];
@ -364,16 +447,38 @@ class _AlbumViewerState extends State<AlbumViewer>
width: thumbSize, height: thumbSize); width: thumbSize, height: thumbSize);
if (file_util.isSupportedImageFormat(f)) { if (file_util.isSupportedImageFormat(f)) {
yield _ImageListItem( yield _ImageListItem(
index: i,
file: f, file: f,
account: widget.account, account: widget.account,
previewUrl: previewUrl, previewUrl: previewUrl,
onTap: () => _onItemTap(i), onTap: () => _onItemTap(i),
onDropBefore: (dropItem) =>
_onItemMoved((dropItem as _ListItem).index, i, true),
onDropAfter: (dropItem) =>
_onItemMoved((dropItem as _ListItem).index, i, false),
onDragStarted: () {
_isDragging = true;
},
onDragEndedAny: () {
_isDragging = false;
},
); );
} else if (file_util.isSupportedVideoFormat(f)) { } else if (file_util.isSupportedVideoFormat(f)) {
yield _VideoListItem( yield _VideoListItem(
index: i,
account: widget.account, account: widget.account,
previewUrl: previewUrl, previewUrl: previewUrl,
onTap: () => _onItemTap(i), onTap: () => _onItemTap(i),
onDropBefore: (dropItem) =>
_onItemMoved((dropItem as _ListItem).index, i, true),
onDropAfter: (dropItem) =>
_onItemMoved((dropItem as _ListItem).index, i, false),
onDragStarted: () {
_isDragging = true;
},
onDragEndedAny: () {
_isDragging = false;
},
); );
} else { } else {
_log.shout( _log.shout(
@ -382,6 +487,8 @@ class _AlbumViewerState extends State<AlbumViewer>
} }
}() }()
.toList(); .toList();
itemStreamListItems = items;
draggableItemList = items;
} }
bool _shouldPropagateResyncedAlbum(Album album) { bool _shouldPropagateResyncedAlbum(Album album) {
@ -415,18 +522,33 @@ class _AlbumViewerState extends State<AlbumViewer>
AlbumStaticProvider.of(a).items; AlbumStaticProvider.of(a).items;
Album _album; Album _album;
var _sortedItems = <AlbumItem>[];
var _backingFiles = <File>[]; var _backingFiles = <File>[];
final _scrollController = ScrollController();
double _itemListMaxExtent;
bool _isDragging = false;
// == null if not drag scrolling
bool _isDragScrollingDown;
final _editFormKey = GlobalKey<FormState>(); final _editFormKey = GlobalKey<FormState>();
Album _editAlbum; Album _editAlbum;
static final _log = Logger("widget.album_viewer._AlbumViewerState"); static final _log = Logger("widget.album_viewer._AlbumViewerState");
} }
abstract class _ListItem implements SelectableItem { abstract class _ListItem implements SelectableItem, DraggableItem {
_ListItem({ _ListItem({
@required this.index,
VoidCallback onTap, VoidCallback onTap,
}) : _onTap = onTap; DragTargetAccept<DraggableItem> onDropBefore,
DragTargetAccept<DraggableItem> onDropAfter,
VoidCallback onDragStarted,
VoidCallback onDragEndedAny,
}) : _onTap = onTap,
_onDropBefore = onDropBefore,
_onDropAfter = onDropAfter,
_onDragStarted = onDragStarted,
_onDragEndedAny = onDragEndedAny;
@override @override
get onTap => _onTap; get onTap => _onTap;
@ -434,19 +556,59 @@ abstract class _ListItem implements SelectableItem {
@override @override
get isSelectable => true; get isSelectable => true;
@override
get isDraggable => true;
@override
get onDropBefore => _onDropBefore;
@override
get onDropAfter => _onDropAfter;
@override
get onDragStarted => _onDragStarted;
@override
get onDragEndedAny => _onDragEndedAny;
@override @override
get staggeredTile => const StaggeredTile.count(1, 1); get staggeredTile => const StaggeredTile.count(1, 1);
@override
toString() {
return "$runtimeType {"
"index: $index, "
"}";
}
final int index;
final VoidCallback _onTap; final VoidCallback _onTap;
final DragTargetAccept<DraggableItem> _onDropBefore;
final DragTargetAccept<DraggableItem> _onDropAfter;
final VoidCallback _onDragStarted;
final VoidCallback _onDragEndedAny;
} }
class _ImageListItem extends _ListItem { class _ImageListItem extends _ListItem {
_ImageListItem({ _ImageListItem({
@required int index,
@required this.file, @required this.file,
@required this.account, @required this.account,
@required this.previewUrl, @required this.previewUrl,
VoidCallback onTap, VoidCallback onTap,
}) : super(onTap: onTap); DragTargetAccept<DraggableItem> onDropBefore,
DragTargetAccept<DraggableItem> onDropAfter,
VoidCallback onDragStarted,
VoidCallback onDragEndedAny,
}) : super(
index: index,
onTap: onTap,
onDropBefore: onDropBefore,
onDropAfter: onDropAfter,
onDragStarted: onDragStarted,
onDragEndedAny: onDragEndedAny,
);
@override @override
buildWidget(BuildContext context) { buildWidget(BuildContext context) {
@ -464,10 +626,22 @@ class _ImageListItem extends _ListItem {
class _VideoListItem extends _ListItem { class _VideoListItem extends _ListItem {
_VideoListItem({ _VideoListItem({
@required int index,
@required this.account, @required this.account,
@required this.previewUrl, @required this.previewUrl,
VoidCallback onTap, VoidCallback onTap,
}) : super(onTap: onTap); DragTargetAccept<DraggableItem> onDropBefore,
DragTargetAccept<DraggableItem> onDropAfter,
VoidCallback onDragStarted,
VoidCallback onDragEndedAny,
}) : super(
index: index,
onTap: onTap,
onDropBefore: onDropBefore,
onDropAfter: onDropAfter,
onDragStarted: onDragStarted,
onDragEndedAny: onDragEndedAny,
);
@override @override
buildWidget(BuildContext context) { buildWidget(BuildContext context) {

128
lib/widget/draggable.dart Normal file
View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
class Draggable<T> extends StatelessWidget {
Draggable({
Key key,
@required this.data,
@required this.child,
this.onDropBefore,
this.onDropAfter,
this.onDragStarted,
this.onDragEndedAny,
this.feedbackSize,
}) : super(key: key);
@override
build(BuildContext context) {
final buildIndicator = (alignment, isActive) {
return Stack(
children: [
Container(),
Visibility(
visible: isActive,
child: Align(
alignment: alignment,
child: Container(
constraints: BoxConstraints.tightFor(width: 2),
color: Theme.of(context).colorScheme.primary,
),
),
),
],
);
};
return Stack(
fit: StackFit.expand,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: LongPressDraggable<T>(
data: data,
dragAnchorStrategy: pointerDragAnchorStrategy,
onDragStarted: onDragStarted,
onDragEnd: (_) => onDragEndedAny?.call(),
onDragCompleted: onDragEndedAny,
onDraggableCanceled: (v, o) => onDragEndedAny?.call(),
feedback: FractionalTranslation(
translation: const Offset(-.5, -.5),
child: SizedBox(
width: feedbackSize?.width ?? 128,
height: feedbackSize?.height ?? 128,
child: Opacity(
opacity: .5,
child: child,
),
),
),
child: child,
childWhenDragging: Opacity(
opacity: .25,
child: child,
),
),
),
if (onDropBefore != null || onDropAfter != null)
Positioned.fill(
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
if (onDropBefore != null)
Expanded(
child: DragTarget<T>(
builder: (context, candidateItems, rejectedItems) {
return buildIndicator(AlignmentDirectional.centerStart,
candidateItems.isNotEmpty);
},
onAccept: (item) {
_log.fine("[build] Dropping $item before $data");
onDropBefore(item);
},
),
),
if (onDropAfter != null)
Expanded(
child: DragTarget<T>(
builder: (context, candidateItems, rejectedItems) {
return buildIndicator(AlignmentDirectional.centerEnd,
candidateItems.isNotEmpty);
},
onAccept: (item) {
_log.fine("[build] Dropping $item after $data");
onDropAfter(item);
},
),
),
],
),
),
],
);
}
final T data;
final Widget child;
/// Called when some item dropped before this item
final DragTargetAccept<T> onDropBefore;
/// Called when some item dropped after this item
final DragTargetAccept<T> onDropAfter;
final VoidCallback onDragStarted;
/// Called when either one of onDragEnd, onDragCompleted or
/// onDraggableCanceled is called.
///
/// The callback might be called multiple times per each drag event
final VoidCallback onDragEndedAny;
/// Size of the feedback widget that appears under the pointer.
///
/// Right now a translucent version of [child] is being shown
final Size feedbackSize;
static final _log = Logger("widget.draggable.Draggable");
}

View file

@ -0,0 +1,55 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:nc_photos/widget/draggable.dart' as _;
import 'package:nc_photos/widget/measurable_item_list.dart';
abstract class DraggableItem {
Widget buildWidget(BuildContext context);
bool get isDraggable => false;
DragTargetAccept<DraggableItem> get onDropBefore => null;
DragTargetAccept<DraggableItem> get onDropAfter => null;
VoidCallback get onDragStarted => null;
VoidCallback get onDragEndedAny => null;
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
}
mixin DraggableItemListMixin<T extends StatefulWidget> on State<T> {
@protected
Widget buildDraggableItemList({
@required double maxCrossAxisExtent,
ValueChanged<double> onMaxExtentChanged,
}) {
_maxCrossAxisExtent = maxCrossAxisExtent;
return MeasurableItemList(
maxCrossAxisExtent: maxCrossAxisExtent,
itemCount: _items.length,
itemBuilder: _buildItem,
staggeredTileBuilder: (index) => _items[index].staggeredTile,
onMaxExtentChanged: onMaxExtentChanged,
);
}
@protected
set draggableItemList(List<DraggableItem> newItems) {
_items = newItems;
}
Widget _buildItem(BuildContext context, int index) {
final item = _items[index];
return _.Draggable(
data: item,
child: item.buildWidget(context),
onDropBefore: item.onDropBefore,
onDropAfter: item.onDropAfter,
onDragStarted: item.onDragStarted,
onDragEndedAny: item.onDragEndedAny,
feedbackSize: _maxCrossAxisExtent != null
? Size(_maxCrossAxisExtent * .65, _maxCrossAxisExtent * .65)
: null,
);
}
var _items = <DraggableItem>[];
double _maxCrossAxisExtent;
}