#4 collection: long press and move to select multiple entries & scroll the grid when close to edge

This commit is contained in:
Thibault Deckers 2020-12-29 18:39:53 +09:00
parent 13a8e23034
commit 3a18f16d7c
7 changed files with 211 additions and 7 deletions

View file

@ -2,6 +2,9 @@
All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- Collection: long press and move to select/deselect multiple entries
- Info: show Spherical Video V1 metadata
## [v1.3.0] - 2020-12-26
### Added

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -143,6 +144,25 @@ class SectionedListLayout {
final top = sectionLayout.indexToLayoutOffset(listIndex);
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
}
ImageEntry getEntryAt(Offset position) {
var dy = position.dy;
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
if (sectionLayout == null) return null;
final section = collection.sections[sectionLayout.sectionKey];
if (section == null) return null;
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
if (dy < 0) return null;
final row = dy ~/ tileExtent;
final column = position.dx ~/ tileExtent;
final index = row * columnCount + column;
if (index >= section.length) return null;
return section[index];
}
}
class SectionLayout {
@ -184,4 +204,7 @@ class SectionLayout {
scrollOffset -= minOffset + headerExtent;
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent}';
}

View file

@ -65,11 +65,6 @@ class GridThumbnail extends StatelessWidget {
ViewerService.pick(entry.uri);
}
},
onLongPress: () {
if (AvesApp.mode == AppMode.main) {
collection.toggleSelection(entry);
}
},
child: MetaData(
metaData: ScalerMetadata(entry),
child: DecoratedThumbnail(

View file

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class GridSelectionGestureDetector extends StatefulWidget {
final bool selectable;
final CollectionLens collection;
final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier;
final Widget child;
const GridSelectionGestureDetector({
this.selectable = true,
@required this.collection,
@required this.scrollController,
@required this.appBarHeightNotifier,
@required this.child,
});
@override
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState();
}
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
bool _pressing, _selecting;
int _fromIndex, _lastToIndex;
Offset _localPosition;
EdgeInsets _scrollableInsets;
double _scrollSpeedFactor;
Timer _updateTimer;
CollectionLens get collection => widget.collection;
List<ImageEntry> get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController;
double get appBarHeight => widget.appBarHeightNotifier.value;
static const double scrollEdgeRatio = .15;
static const double scrollMaxPixelPerSecond = 600.0;
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPressStart: widget.selectable
? (details) {
final fromEntry = _getEntryAt(details.localPosition);
if (fromEntry == null) return;
collection.toggleSelection(fromEntry);
_selecting = collection.isSelected([fromEntry]);
_fromIndex = entries.indexOf(fromEntry);
_lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only(
top: appBarHeight,
bottom: context.read<MediaQueryData>().viewInsets.bottom,
);
_scrollSpeedFactor = 0;
_pressing = true;
}
: null,
onLongPressMoveUpdate: widget.selectable
? (details) {
if (!_pressing) return;
_localPosition = details.localPosition;
_onLongPressUpdate();
}
: null,
onLongPressEnd: widget.selectable
? (details) {
if (!_pressing) return;
_setScrollSpeed(0);
_pressing = false;
}
: null,
child: widget.child,
);
}
void _onLongPressUpdate() {
final dy = _localPosition.dy;
final height = scrollController.position.viewportDimension;
final top = dy < height / 2;
final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom);
final threshold = height * scrollEdgeRatio;
if (distanceToEdge < threshold) {
_setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1));
} else {
_setScrollSpeed(0);
}
final toEntry = _getEntryAt(_localPosition);
_toggleSelectionToIndex(entries.indexOf(toEntry));
}
void _setScrollSpeed(double speedFactor) {
if (speedFactor == _scrollSpeedFactor) return;
_scrollSpeedFactor = speedFactor;
_updateTimer?.cancel();
final current = scrollController.offset;
if (speedFactor == 0) {
scrollController.jumpTo(current);
return;
}
final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0;
if (target != current) {
final distance = target - current;
final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor;
scrollController.animateTo(
target,
duration: Duration(milliseconds: millis.round()),
curve: Curves.linear,
);
// use a timer to update the entry selection, because `onLongPressMoveUpdate`
// is not called when the pointer stays still while the view is scrolling
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
}
}
ImageEntry _getEntryAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
return context.read<SectionedListLayout>().getEntryAt(offset);
}
void _toggleSelectionToIndex(int toIndex) {
if (toIndex == -1) return;
if (_selecting) {
if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) {
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_fromIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1));
}
} else if (_lastToIndex < toIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
}
} else if (_fromIndex < toIndex) {
if (_lastToIndex < toIndex) {
collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
if (_lastToIndex < _fromIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
}
} else if (toIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
}
}
_lastToIndex = toIndex;
} else {
collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
}
}
}

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/highlight.dart';
@ -13,6 +14,7 @@ import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
import 'package:aves/widgets/collection/grid/list_sliver.dart';
import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget {
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context);
// do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes
@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget {
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context),
scrollController: scrollController,
cacheExtent: cacheExtent,
);
@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget {
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget {
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: scaler,
child: selector,
),
);
return sectionedListLayoutProvider;

View file

@ -168,6 +168,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: (details) => _tapPosition = details.globalPosition,
onTap: widget.onTap != null
? () {

View file

@ -85,6 +85,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40);
// TODO TLAD show menu within safe area
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),