diff --git a/lib/src/constants/config.dart b/lib/src/constants/config.dart index 939f5fa8..6832f2cc 100644 --- a/lib/src/constants/config.dart +++ b/lib/src/constants/config.dart @@ -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.', @@ -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; } diff --git a/lib/src/delegates/asset_grid_drag_selection_coordinator.dart b/lib/src/delegates/asset_grid_drag_selection_coordinator.dart new file mode 100644 index 00000000..4a3f3400 --- /dev/null +++ b/lib/src/delegates/asset_grid_drag_selection_coordinator.dart @@ -0,0 +1,299 @@ +// 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 +/// the [DefaultAssetPickerBuilderDelegate] and the [DefaultAssetPickerProvider]. +class AssetGridDragSelectionCoordinator { + AssetGridDragSelectionCoordinator({ + required this.delegate, + }); + + /// [ChangeNotifier] for asset picker. + /// 资源选择器状态保持 + 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; + + /// 长按启动拖拽 + /// Long Press to enable drag and select + void onSelectionStart( + BuildContext context, + Offset globalPosition, + int index, + AssetEntity entity, + ) { + dragging = true; + + final scrollableState = _checkScrollableStatePresent(context); + if (scrollableState == null) { + return; + } + + _autoScroller = EdgeDraggingAutoScroller( + scrollableState, + velocityScalar: _kDefaultAutoScrollVelocityScalar, + ); + + initialSelectingIndex = index; + largestSelectingIndex = index; + smallestSelectingIndex = index; + + addSelected = !delegate.provider.selectedAssets.contains(entity); + } + + void onSelectionUpdate(BuildContext context, Offset globalPosition) { + if (!dragging) { + return; + } + + final view = View.of(context); + final dimensionSize = view.physicalSize / view.devicePixelRatio; + + // Calculate the coordinate of the current drag position's + // asset representation. + final gridCount = delegate.gridCount; + final itemSize = dimensionSize.width / gridCount; + + // 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 topPadding = + appBarSize.height + view.viewPadding.top / view.devicePixelRatio; + final bottomPadding = delegate.bottomActionBarHeight + + view.viewPadding.bottom / view.devicePixelRatio; + + // 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); + int columnIndex = _getDragPositionIndex(globalPosition.dx, itemSize); + final maxRow = (provider.currentAssets.length / gridCount).ceil(); + final maxRowPerPage = + ((dimensionSize.height - bottomPadding) / itemSize).ceil(); + + final int placeholderCount; + if (gridRevert) { + // Get the total asset count of the current Asset path + final totalCount = provider.currentPath?.assetCount ?? 0; + // Check if special item does exist + final specialItemExist = delegate.shouldBuildSpecialItem; + final lastRowCount = + (totalCount + (specialItemExist ? 1 : 0)) % gridCount; + placeholderCount = gridCount - lastRowCount; + } else { + placeholderCount = 0; + } + + if (gridRevert) { + columnIndex = gridCount - columnIndex - placeholderCount; + if (maxRow > maxRowPerPage) { + columnIndex -= 1; + } + } + + final double dividedSpacing = delegate.itemSpacing / gridCount; + final double anchor = math.min( + (maxRow * (itemSize + dividedSpacing) + + topPadding - + delegate.itemSpacing) / + dimensionSize.height, + 1, + ); + + final bottomGap = anchor == 1.0 && delegate.isAppleOS(context) && gridRevert + ? delegate.bottomActionBarHeight + : MediaQuery.paddingOf(context).bottom + + delegate.bottomSectionHeight - + (delegate.isAppleOS(context) + ? delegate.permissionLimitedBarHeight + : 0); + + int rowIndex = _getDragPositionIndex( + switch (gridRevert) { + true => dimensionSize.height - + topPadding - + globalPosition.dy + + (delegate.gridScrollController.offset <= 0 ? bottomGap : 0) + + -delegate.gridScrollController.offset, + false => + globalPosition.dy - topPadding + delegate.gridScrollController.offset, + }, + itemSize, + ); + + final double initialFirstPosition = dimensionSize.height * anchor; + if (gridRevert && dimensionSize.height > initialFirstPosition) { + final deductedRow = + (dimensionSize.height - initialFirstPosition) ~/ itemSize; + rowIndex -= deductedRow; + } + + if (placeholderCount > 0 && + maxRow > maxRowPerPage && + rowIndex > 0 && + anchor < 1.0) { + rowIndex -= 1; + } + + 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, + ); + + // To avoid array indexed out of bounds + largestSelectingIndex = math.min( + math.max(0, largestSelectingIndex), + provider.currentAssets.length, + ); + + // Filter out pending assets to manipulate. + final Iterable 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 + (maxRow > maxRowPerPage ? 1 : 0), + provider.currentAssets.length, + ), + ); + } + final touchedAssets = List.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 drag detail is at edge + _autoScroller?.startAutoScrollIfNecessary( + Offset( + (columnIndex + 1) * itemSize, + globalPosition.dy > dimensionSize.height * 0.8 + ? (rowIndex + 1) * itemSize + : math.max(topPadding, globalPosition.dy), + ) & + Size.square(itemSize), + ); + } + + void onDragEnd(Offset globalPosition) { + resetDraggingStatus(); + } + + /// 复原拖拽状态 + /// Reset dragging status + void resetDraggingStatus() { + _autoScroller?.stopAutoScroll(); + _autoScroller = null; + dragging = false; + addSelected = true; + initialSelectingIndex = -1; + largestSelectingIndex = -1; + smallestSelectingIndex = -1; + } + + /// 检查 [Scrollable] state是否存在 + /// Check if the [Scrollable] state is exist + /// + /// This is to ensure 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; + } + + /// 获取坐标 + /// Get Coordinate Helper + int _getDragPositionIndex(double delta, double itemSize) { + return delta ~/ itemSize; + } +} diff --git a/lib/src/delegates/asset_picker_builder_delegate.dart b/lib/src/delegates/asset_picker_builder_delegate.dart index aa85ec03..23d1d030 100644 --- a/lib/src/delegates/asset_picker_builder_delegate.dart +++ b/lib/src/delegates/asset_picker_builder_delegate.dart @@ -18,6 +18,7 @@ import 'package:wechat_picker_library/wechat_picker_library.dart'; import '../constants/constants.dart'; import '../constants/enums.dart'; import '../constants/typedefs.dart'; +import '../delegates/asset_grid_drag_selection_coordinator.dart'; import '../delegates/asset_picker_text_delegate.dart'; import '../internals/singleton.dart'; import '../models/path_wrapper.dart'; @@ -760,11 +761,13 @@ class DefaultAssetPickerBuilderDelegate this.specialPickerType, this.keepScrollOffset = false, this.shouldAutoplayPreview = false, + this.dragToSelect, }) { // Add the listener if [keepScrollOffset] is true. if (keepScrollOffset) { gridScrollController.addListener(keepScrollOffsetListener); } + dragSelectCoordinator = AssetGridDragSelectionCoordinator(delegate: this); } /// [ChangeNotifier] for asset picker. @@ -810,6 +813,10 @@ class DefaultAssetPickerBuilderDelegate /// * [SpecialPickerType.noPreview] 禁用资源预览。多选时单击资产将直接选中,单选时选中并返回。 final SpecialPickerType? specialPickerType; + /// Drag select aggregator. + /// 拖拽选择协调器 + late final AssetGridDragSelectionCoordinator dragSelectCoordinator; + /// Whether the picker should save the scroll offset between pushes and pops. /// 选择器是否可以从同样的位置开始选择 final bool keepScrollOffset; @@ -818,6 +825,9 @@ class DefaultAssetPickerBuilderDelegate /// 预览是否自动播放 final bool shouldAutoplayPreview; + /// {@macro wechat_assets_picker.constants.AssetPickerConfig.dragToSelect} + final bool? dragToSelect; + /// [Duration] when triggering path switching. /// 切换路径时的动画时长 Duration get switchingPathDuration => const Duration(milliseconds: 300); @@ -1267,7 +1277,10 @@ class DefaultAssetPickerBuilderDelegate final double topPadding = context.topPadding + appBarPreferredSize!.height; - final textDirection = Directionality.of(context); + // Obtain the text direction from the correct context and apply to + // the grid item before it gets manipulated by the grid revert. + final textDirectionCorrection = Directionality.of(context); + Widget sliverGrid(BuildContext context, List assets) { return SliverGrid( delegate: SliverChildBuilderDelegate( @@ -1278,15 +1291,82 @@ class DefaultAssetPickerBuilderDelegate } index -= placeholderCount; } + + Widget child = assetGridItemBuilder( + context, + index, + assets, + specialItem: specialItem, + ); + + if (dragToSelect ?? + !MediaQuery.accessibleNavigationOf(context)) { + child = GestureDetector( + excludeFromSemantics: true, + onHorizontalDragStart: (d) { + dragSelectCoordinator.onSelectionStart( + context, + d.globalPosition, + index, + assets[index], + ); + }, + onHorizontalDragUpdate: (d) { + dragSelectCoordinator.onSelectionUpdate( + context, + d.globalPosition, + ); + }, + onHorizontalDragCancel: + dragSelectCoordinator.resetDraggingStatus, + onHorizontalDragEnd: (d) { + dragSelectCoordinator.onDragEnd(d.globalPosition); + }, + onLongPressStart: (d) { + dragSelectCoordinator.onSelectionStart( + context, + d.globalPosition, + index, + assets[index], + ); + }, + onLongPressMoveUpdate: (d) { + dragSelectCoordinator.onSelectionUpdate( + context, + d.globalPosition, + ); + }, + onLongPressCancel: + dragSelectCoordinator.resetDraggingStatus, + onLongPressEnd: (d) { + dragSelectCoordinator.onDragEnd(d.globalPosition); + }, + onPanStart: (d) { + dragSelectCoordinator.onSelectionStart( + context, + d.globalPosition, + index, + assets[index], + ); + }, + onPanUpdate: (d) { + dragSelectCoordinator.onSelectionUpdate( + context, + d.globalPosition, + ); + }, + onPanCancel: dragSelectCoordinator.resetDraggingStatus, + onPanEnd: (d) { + dragSelectCoordinator.onDragEnd(d.globalPosition); + }, + child: child, + ); + } + return MergeSemantics( child: Directionality( - textDirection: textDirection, - child: assetGridItemBuilder( - context, - index, - assets, - specialItem: specialItem, - ), + textDirection: textDirectionCorrection, + child: child, ), ); }, @@ -1367,13 +1447,14 @@ class DefaultAssetPickerBuilderDelegate ), sliverGrid(context, assets), // Ignore the gap when the [anchor] is not equal to 1. - if (gridRevert && anchor == 1) bottomGap, + if (gridRevert && isAppleOS(context) && anchor == 1) + bottomGap, if (gridRevert) SliverToBoxAdapter( key: gridRevertKey, child: const SizedBox.shrink(), ), - if (isAppleOS(context) && !gridRevert) bottomGap, + if (!gridRevert && isAppleOS(context)) bottomGap, ], ); }, @@ -1453,7 +1534,7 @@ class DefaultAssetPickerBuilderDelegate builder, selectedBackdrop(context, currentIndex, asset), if (!isWeChatMoment || asset.type != AssetType.video) - selectIndicator(context, index, asset), + selectIndicator(context, currentIndex, asset), itemBannedIndicator(context, asset), ], ); diff --git a/lib/src/delegates/asset_picker_delegate.dart b/lib/src/delegates/asset_picker_delegate.dart index 3f788b3d..1d7af894 100644 --- a/lib/src/delegates/asset_picker_delegate.dart +++ b/lib/src/delegates/asset_picker_delegate.dart @@ -119,6 +119,7 @@ class AssetPickerDelegate { themeColor: pickerConfig.themeColor, locale: Localizations.maybeLocaleOf(context), shouldAutoplayPreview: pickerConfig.shouldAutoplayPreview, + dragToSelect: pickerConfig.dragToSelect, ), ); final List? result = await Navigator.maybeOf(