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": {
"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": {

View file

@ -9,5 +9,8 @@ class SessionStorage {
/// Whether the range select notification has been shown to user
bool hasShowRangeSelectNotification = false;
/// Whether the drag to rearrange notification has been shown
bool hasShowDragRearrangeNotification = false;
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/k.dart' as k;
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/theme.dart';
import 'package:nc_photos/use_case/resync_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/draggable_item_list_mixin.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/viewer.dart';
@ -59,6 +61,7 @@ class _AlbumViewerState extends State<AlbumViewer>
with
WidgetsBindingObserver,
SelectableItemStreamListMixin<AlbumViewer>,
DraggableItemListMixin<AlbumViewer>,
AlbumViewerMixin<AlbumViewer> {
@override
initState() {
@ -90,6 +93,15 @@ class _AlbumViewerState extends State<AlbumViewer>
enterEditMode() {
super.enterEditMode();
_editAlbum = _album.copyWith();
if (!SessionStorage().hasShowDragRearrangeNotification) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context).albumEditDragRearrangeNotification),
duration: k.snackBarDurationNormal,
));
SessionStorage().hasShowDragRearrangeNotification = true;
}
}
@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(
context,
child: Theme(
data: Theme.of(context).copyWith(
accentColor: AppTheme.getOverscrollIndicatorColor(context),
),
child: CustomScrollView(
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(),
),
),
),
),
],
),
child: content,
),
);
}
@ -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() {
List<AlbumItem> sortedItems;
if (_editAlbum != null) {
// edit mode
sortedItems = _editAlbum.sortProvider.sort(_getAlbumItemsOf(_editAlbum));
_sortedItems = _editAlbum.sortProvider.sort(_getAlbumItemsOf(_editAlbum));
} else {
sortedItems = _album.sortProvider.sort(_getAlbumItemsOf(_album));
_sortedItems = _album.sortProvider.sort(_getAlbumItemsOf(_album));
}
_backingFiles = sortedItems
_backingFiles = _sortedItems
.whereType<AlbumFileItem>()
.map((e) => e.file)
.where((element) => file_util.isSupportedFormat(element))
.toList();
itemStreamListItems = () sync* {
final items = () sync* {
for (int i = 0; i < _backingFiles.length; ++i) {
final f = _backingFiles[i];
@ -364,16 +447,38 @@ class _AlbumViewerState extends State<AlbumViewer>
width: thumbSize, height: thumbSize);
if (file_util.isSupportedImageFormat(f)) {
yield _ImageListItem(
index: i,
file: f,
account: widget.account,
previewUrl: previewUrl,
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)) {
yield _VideoListItem(
index: i,
account: widget.account,
previewUrl: previewUrl,
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 {
_log.shout(
@ -382,6 +487,8 @@ class _AlbumViewerState extends State<AlbumViewer>
}
}()
.toList();
itemStreamListItems = items;
draggableItemList = items;
}
bool _shouldPropagateResyncedAlbum(Album album) {
@ -415,18 +522,33 @@ class _AlbumViewerState extends State<AlbumViewer>
AlbumStaticProvider.of(a).items;
Album _album;
var _sortedItems = <AlbumItem>[];
var _backingFiles = <File>[];
final _scrollController = ScrollController();
double _itemListMaxExtent;
bool _isDragging = false;
// == null if not drag scrolling
bool _isDragScrollingDown;
final _editFormKey = GlobalKey<FormState>();
Album _editAlbum;
static final _log = Logger("widget.album_viewer._AlbumViewerState");
}
abstract class _ListItem implements SelectableItem {
abstract class _ListItem implements SelectableItem, DraggableItem {
_ListItem({
@required this.index,
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
get onTap => _onTap;
@ -434,19 +556,59 @@ abstract class _ListItem implements SelectableItem {
@override
get isSelectable => true;
@override
get isDraggable => true;
@override
get onDropBefore => _onDropBefore;
@override
get onDropAfter => _onDropAfter;
@override
get onDragStarted => _onDragStarted;
@override
get onDragEndedAny => _onDragEndedAny;
@override
get staggeredTile => const StaggeredTile.count(1, 1);
@override
toString() {
return "$runtimeType {"
"index: $index, "
"}";
}
final int index;
final VoidCallback _onTap;
final DragTargetAccept<DraggableItem> _onDropBefore;
final DragTargetAccept<DraggableItem> _onDropAfter;
final VoidCallback _onDragStarted;
final VoidCallback _onDragEndedAny;
}
class _ImageListItem extends _ListItem {
_ImageListItem({
@required int index,
@required this.file,
@required this.account,
@required this.previewUrl,
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
buildWidget(BuildContext context) {
@ -464,10 +626,22 @@ class _ImageListItem extends _ListItem {
class _VideoListItem extends _ListItem {
_VideoListItem({
@required int index,
@required this.account,
@required this.previewUrl,
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
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;
}