Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

➕ Support Drag and Select feature #175 #660

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
130189a
➕ Support Drag and Select feature
WeiJun0507 Nov 9, 2024
a1e594d
➕ Support Drag and Select feature
WeiJun0507 Nov 17, 2024
aa6f46e
➕ Support Drag and Select feature
WeiJun0507 Nov 17, 2024
0e82a3b
➕ Support Drag and Select feature
WeiJun0507 Nov 23, 2024
3faa7a0
Merge branch 'fluttercandies:main' into drag_select_featue_v2
WeiJun0507 Nov 23, 2024
6a366fb
➕ Support Drag and Select feature
WeiJun0507 Nov 23, 2024
1f989bb
➕ Support Drag and Select feature
WeiJun0507 Nov 24, 2024
c42e7cc
➕ Support Drag and Select feature
WeiJun0507 Nov 24, 2024
f7dd57d
♻️ Use horizonal gestures to trigger aggregator calculations
AlexV525 Dec 2, 2024
ff77528
♻️ Refine the coordinator and add multiple gestures support
AlexV525 Dec 3, 2024
6bca4c2
♻️ `enableDragAndSelect` -> `dragToSelect`
AlexV525 Dec 3, 2024
cb46460
🐛 Fix dimension calculations
AlexV525 Dec 4, 2024
b6bcc6e
🐛 Fix the missing range from the largest index
AlexV525 Dec 4, 2024
ec57fba
♿️ Try to fix accessibility issues
AlexV525 Dec 4, 2024
9a1bcdf
🐛 Fix grid revert calculations
AlexV525 Dec 4, 2024
3026576
🐛 Fix column and row index inaccurate when asset is less than a page
WeiJun0507 Dec 12, 2024
9388ada
🐛 Fix android revert drag select calculation
WeiJun0507 Dec 17, 2024
f72478d
⚡️ Optimize column and row index calculation in reverse grid
WeiJun0507 Dec 18, 2024
f9016c1
:bug: Fix drag select index accuracy when asset size is one page
WeiJun0507 Dec 23, 2024
77d4429
Revert ":bug: Fix drag select index accuracy when asset size is one p…
WeiJun0507 Dec 23, 2024
dfa8352
🐛 Fix drag select index accuracy when asset size is one page
WeiJun0507 Dec 23, 2024
f39b2ba
🐛 Improve the accuracy of select asset on iOS device
WeiJun0507 Dec 30, 2024
8580c40
🐛 Fix bottom gaps when grid reverting on Android
AlexV525 Dec 31, 2024
60239f6
🐛 Improve the accuracy of select asset on iOS device
WeiJun0507 Jan 4, 2025
643af2f
🐛 Fix anchors and reverts
AlexV525 Jan 7, 2025
a02aa2c
🐛 Fix placeholders count
AlexV525 Jan 7, 2025
7eba1b4
⚡️ Unify calculations and comments
AlexV525 Jan 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/src/constants/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class AssetPickerConfig {
this.assetsChangeCallback,
this.assetsChangeRefreshPredicate,
this.shouldAutoplayPreview = false,
this.dragToSelect,
}) : assert(
pickerTheme == null || themeColor == null,
'pickerTheme and themeColor cannot be set at the same time.',
Expand Down Expand Up @@ -205,4 +206,13 @@ class AssetPickerConfig {
/// Whether the preview should auto play.
/// 预览是否自动播放
final bool shouldAutoplayPreview;

/// {@template wechat_assets_picker.constants.AssetPickerConfig.dragToSelect}
/// Whether assets selection can be done with drag gestures.
/// 是否开启拖拽选择
///
/// The feature enables by default if no accessibility service is being used.
/// 在未使用辅助功能的情况下会默认启用该功能。
/// {@endtemplate}
final bool? dragToSelect;
}
295 changes: 295 additions & 0 deletions lib/src/delegates/asset_grid_drag_selection_coordinator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
// Copyright 2019 The FlutterCandies author. All rights reserved.
// Use of this source code is governed by an Apache license that can be found
// in the LICENSE file.

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart' show AssetEntity;

import '../provider/asset_picker_provider.dart';
import 'asset_picker_builder_delegate.dart';

/// The coordinator that will calculates the corresponding item position
/// based on gesture details. This will only works with
/// [DefaultAssetPickerBuilderDelegate] and [DefaultAssetPickerProvider].
class AssetGridDragSelectionCoordinator {
AssetGridDragSelectionCoordinator({
required this.delegate,
});

/// Access the delegate to calculate layout details.
final DefaultAssetPickerBuilderDelegate delegate;

// An eyeballed value for a smooth scrolling experience.
static const double _kDefaultAutoScrollVelocityScalar = 50.0;

/// Support edge auto scroll when drag positions reach
/// the edge of device's screen.
EdgeDraggingAutoScroller? _autoScroller;

/// The first selecting item index.
int initialSelectingIndex = -1;

int largestSelectingIndex = -1;
int smallestSelectingIndex = -1;

/// Dragging status.
bool dragging = false;

/// Whether to add or to remove the selected assets.
bool addSelected = true;

DefaultAssetPickerProvider get provider => delegate.provider;

bool get _debug => true;
final _debugLastPosition = ValueNotifier<(int, int)?>(null);

/// Reset all dragging status.
void resetDraggingStatus() {
_autoScroller?.stopAutoScroll();
_autoScroller = null;
dragging = false;
addSelected = true;
initialSelectingIndex = -1;
largestSelectingIndex = -1;
smallestSelectingIndex = -1;
_debugLastPosition.value = null;
}

/// Long Press or horizontal drag to start the selection.
void onSelectionStart({
required BuildContext context,
required Offset globalPosition,
required int index,
required AssetEntity asset,
}) {
final scrollableState = _checkScrollableStatePresent(context);
if (scrollableState == null) {
return;
}

if (delegate.gridScrollController.position.isScrollingNotifier.value) {
return;
}

dragging = true;

_autoScroller = EdgeDraggingAutoScroller(
scrollableState,
velocityScalar: _kDefaultAutoScrollVelocityScalar,
);

initialSelectingIndex = index;
largestSelectingIndex = index;
smallestSelectingIndex = index;

addSelected = !delegate.provider.selectedAssets.contains(asset);
}

void onSelectionUpdate({
required BuildContext context,
required Offset globalPosition,
required BoxConstraints constraints,
}) {
if (!dragging) {
return;
}

final view = View.of(context);
final dimensionSize = view.physicalSize / view.devicePixelRatio;

// Get the actual top padding. Since `viewPadding` represents the
// physical pixels, it should be divided by the device pixel ratio
// to get the logical pixels.
final appBarSize =
delegate.appBarPreferredSize ?? delegate.appBar(context).preferredSize;
final viewPaddingTop = view.viewPadding.top / view.devicePixelRatio;
final viewPaddingBottom = view.viewPadding.bottom / view.devicePixelRatio;
final topSectionHeight = appBarSize.height + viewPaddingTop;
final bottomSectionHeight =
delegate.bottomActionBarHeight + viewPaddingBottom;
final gridViewport =
dimensionSize.height - topSectionHeight - bottomSectionHeight;

// Calculate the coordinate of the current drag position's
// asset representation.
final gridCount = delegate.gridCount;
final itemSize = dimensionSize.width / gridCount;
final dividedSpacing = delegate.itemSpacing / gridCount;

// Row index is calculated based on the drag's global position.
// The AppBar height, status bar height, and scroll offset are subtracted
// to adjust for padding and scrolling. This gives the actual row index.
final gridRevert = delegate.effectiveShouldRevertGrid(context);
final totalRows = (provider.currentAssets.length / gridCount).ceil();
final onlyOneScreen =
totalRows * (itemSize + delegate.itemSpacing) <= gridViewport;
final reverted = gridRevert && !onlyOneScreen;

final double anchor = delegate.assetGridAnchor(
context: context,
constraints: constraints,
pathWrapper: provider.currentPath,
);

int getDragAxisIndex(double delta, double itemSize) {
return delta ~/ (itemSize + dividedSpacing);
}

int rowIndex = getDragAxisIndex(
switch (reverted) {
true => dimensionSize.height -
delegate.bottomSectionHeight -
delegate.gridScrollController.offset -
globalPosition.dy,
false => globalPosition.dy -
topSectionHeight +
delegate.gridScrollController.offset,
},
itemSize,
);

final initialFirstPosition = dimensionSize.height * anchor;
if (reverted && dimensionSize.height > initialFirstPosition) {
final deductedRow = getDragAxisIndex(
dimensionSize.height - initialFirstPosition,
itemSize,
);
rowIndex -= deductedRow;
}

final placeholderCount = delegate.assetsGridItemPlaceholderCount(
context: context,
pathWrapper: provider.currentPath,
onlyOneScreen: onlyOneScreen,
);
// Make the index starts with the bottom if the grid is reverted.
if (reverted && placeholderCount > 0 && rowIndex > 0 && anchor < 1.0) {
rowIndex -= 1;
}

int columnIndex = getDragAxisIndex(globalPosition.dx, itemSize);
if (reverted) {
columnIndex = gridCount - columnIndex - placeholderCount - 1;
}

_debugLastPosition.value = (rowIndex, columnIndex);
final currentDragIndex = rowIndex * gridCount + columnIndex;

// Check the selecting index in order to diff unselecting assets.
smallestSelectingIndex = math.min(
currentDragIndex,
smallestSelectingIndex,
);
smallestSelectingIndex = math.max(0, smallestSelectingIndex);
largestSelectingIndex = math.max(
currentDragIndex,
largestSelectingIndex,
);

// Avoid index overflow.
largestSelectingIndex = math.min(
math.max(0, largestSelectingIndex),
provider.currentAssets.length,
);

// Filter out pending assets to manipulate.
final Iterable<AssetEntity> filteredAssetList;
if (currentDragIndex < initialSelectingIndex) {
filteredAssetList = provider.currentAssets
.getRange(
math.max(0, currentDragIndex),
math.min(initialSelectingIndex + 1, provider.currentAssets.length),
)
.toList()
.reversed;
} else {
filteredAssetList = provider.currentAssets.getRange(
math.max(0, initialSelectingIndex),
math.min(currentDragIndex + 1, provider.currentAssets.length),
);
}
final touchedAssets = List<AssetEntity>.from(
provider.currentAssets.getRange(
math.max(0, smallestSelectingIndex),
math.min(largestSelectingIndex + 1, provider.currentAssets.length),
),
);

// Toggle all filtered assets.
for (final asset in filteredAssetList) {
delegate.selectAsset(context, asset, currentDragIndex, !addSelected);
touchedAssets.remove(asset);
}
// Revert the selection of touched but not filtered assets.
for (final asset in touchedAssets) {
delegate.selectAsset(context, asset, currentDragIndex, addSelected);
}

final stopAutoScroll = switch (addSelected) {
true => provider.selectedAssets.length == provider.maxAssets ||
(gridRevert && delegate.gridScrollController.offset == 0.0),
false => provider.selectedAssets.isEmpty,
};
if (stopAutoScroll) {
_autoScroller?.stopAutoScroll();
return;
}

// Enable auto-scrolling if the pointer is at the edge.
_autoScroller?.startAutoScrollIfNecessary(
Offset(
(columnIndex + 1) * itemSize,
globalPosition.dy > constraints.maxHeight * 0.8
? (rowIndex + 1) * itemSize
: math.max(topSectionHeight, globalPosition.dy),
) &
Size.square(itemSize),
);
}

void onDragEnd({required Offset globalPosition}) {
resetDraggingStatus();
}

/// Check if the [Scrollable] state is exist.
///
/// Ensures that the edge auto scrolling is functioning and the drag function
/// is placed correctly inside the [Scrollable].
ScrollableState? _checkScrollableStatePresent(BuildContext context) {
final scrollable = Scrollable.maybeOf(context);
assert(
scrollable != null,
'The drag select feature must use along with scrollables.',
);
assert(
scrollable?.position.axis == Axis.vertical,
'The drag select feature must use along with vertical scrollables.',
);
if (scrollable == null || scrollable.position.axis != Axis.vertical) {
resetDraggingStatus();
return null;
}

return scrollable;
}

Widget buildDebugInfo(BuildContext context) {
if (!_debug) {
return const SizedBox.shrink();
}
return Align(
alignment: Alignment.bottomCenter,
child: ValueListenableBuilder(
valueListenable: _debugLastPosition,
builder: (_, value, __) => Text(
value?.toString() ?? '',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
shadows: const [Shadow(blurRadius: 8.0)],
),
),
),
);
}
}
Loading