#4 collection: long press and move to select multiple entries & scroll the grid when close to edge
This commit is contained in:
parent
13a8e23034
commit
3a18f16d7c
7 changed files with 211 additions and 7 deletions
|
@ -2,6 +2,9 @@
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- Collection: long press and move to select/deselect multiple entries
|
||||||
|
- Info: show Spherical Video V1 metadata
|
||||||
|
|
||||||
## [v1.3.0] - 2020-12-26
|
## [v1.3.0] - 2020-12-26
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -143,6 +144,25 @@ class SectionedListLayout {
|
||||||
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
||||||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
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 {
|
class SectionLayout {
|
||||||
|
@ -184,4 +204,7 @@ class SectionLayout {
|
||||||
scrollOffset -= minOffset + headerExtent;
|
scrollOffset -= minOffset + headerExtent;
|
||||||
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
|
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}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,11 +65,6 @@ class GridThumbnail extends StatelessWidget {
|
||||||
ViewerService.pick(entry.uri);
|
ViewerService.pick(entry.uri);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
|
||||||
if (AvesApp.mode == AppMode.main) {
|
|
||||||
collection.toggleSelection(entry);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: MetaData(
|
child: MetaData(
|
||||||
metaData: ScalerMetadata(entry),
|
metaData: ScalerMetadata(entry),
|
||||||
child: DecoratedThumbnail(
|
child: DecoratedThumbnail(
|
||||||
|
|
169
lib/widgets/collection/grid/selector.dart
Normal file
169
lib/widgets/collection/grid/selector.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/main.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
import 'package:aves/model/filters/mime.dart';
|
||||||
import 'package:aves/model/highlight.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/empty.dart';
|
||||||
import 'package:aves/widgets/collection/grid/list_section_layout.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/list_sliver.dart';
|
||||||
|
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||||
|
@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
)..applyTileExtent(viewportSize: viewportSize);
|
)..applyTileExtent(viewportSize: viewportSize);
|
||||||
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
||||||
|
final scrollController = PrimaryScrollController.of(context);
|
||||||
|
|
||||||
// do not replace by Provider.of<CollectionLens>
|
// do not replace by Provider.of<CollectionLens>
|
||||||
// so that view updates on collection filter changes
|
// so that view updates on collection filter changes
|
||||||
|
@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
scrollController: PrimaryScrollController.of(context),
|
scrollController: scrollController,
|
||||||
cacheExtent: cacheExtent,
|
cacheExtent: cacheExtent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
child: scrollView,
|
child: scrollView,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final selector = GridSelectionGestureDetector(
|
||||||
|
selectable: AvesApp.mode == AppMode.main,
|
||||||
|
collection: collection,
|
||||||
|
scrollController: scrollController,
|
||||||
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
|
child: scaler,
|
||||||
|
);
|
||||||
|
|
||||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||||
valueListenable: _tileExtentNotifier,
|
valueListenable: _tileExtentNotifier,
|
||||||
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
|
||||||
|
@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
tileExtent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
isScrollingNotifier: _isScrollingNotifier,
|
isScrollingNotifier: _isScrollingNotifier,
|
||||||
),
|
),
|
||||||
child: scaler,
|
child: selector,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return sectionedListLayoutProvider;
|
return sectionedListLayoutProvider;
|
||||||
|
|
|
@ -168,6 +168,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
),
|
),
|
||||||
child: InkWell(
|
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,
|
onTapDown: (details) => _tapPosition = details.globalPosition,
|
||||||
onTap: widget.onTap != null
|
onTap: widget.onTap != null
|
||||||
? () {
|
? () {
|
||||||
|
|
|
@ -85,6 +85,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
|
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
|
||||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
||||||
final touchArea = Size(40, 40);
|
final touchArea = Size(40, 40);
|
||||||
|
// TODO TLAD show menu within safe area
|
||||||
final selectedAction = await showMenu<ChipAction>(
|
final selectedAction = await showMenu<ChipAction>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||||
|
|
Loading…
Reference in a new issue