From 55c09395e44700d1eb05c4a825f703c812f89704 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Sun, 13 Oct 2024 14:09:13 +0530 Subject: [PATCH] Add drag and tap gestures to select images --- .../ui/screens/CustomSelectorEvent.kt | 3 +- .../ui/screens/CustomSelectorState.kt | 8 +- .../ui/screens/CustomSelectorViewModel.kt | 21 +- .../customselector/ui/screens/ImagesPane.kt | 222 ++++++++++++------ 4 files changed, 174 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt index 523f09188..fe6ea6bc7 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt @@ -2,5 +2,6 @@ package fr.free.nrw.commons.customselector.ui.screens interface CustomSelectorEvent { data class OnFolderClick(val bucketId: Long): CustomSelectorEvent - data class OnImageSelect(val imageId: Long): CustomSelectorEvent + data class OnImageSelection(val imageId: Long): CustomSelectorEvent + data class OnDragImageSelection(val imageIds: Set): CustomSelectorEvent } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt index 73840ae9d..11d88bf9b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt @@ -5,5 +5,9 @@ import fr.free.nrw.commons.customselector.model.Image data class CustomSelectorState( val isLoading: Boolean = false, val folders: List = emptyList(), - val filteredImages: List = emptyList() -) \ No newline at end of file + val filteredImages: List = emptyList(), + val selectedImageIds: Set = emptySet() +) { + val inSelectionMode: Boolean + get() = selectedImageIds.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt index d21ecb627..5d06671ea 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt @@ -1,6 +1,5 @@ package fr.free.nrw.commons.customselector.ui.screens -import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.free.nrw.commons.customselector.data.MediaReader @@ -17,9 +16,6 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() private val foldersMap = mutableMapOf>() - private var _selectedImageIds = mutableStateListOf() - val selectedImageIds = _selectedImageIds - init { _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { @@ -46,14 +42,21 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() } } - is CustomSelectorEvent.OnImageSelect -> { - if(_selectedImageIds.contains(e.imageId)) { - _selectedImageIds.remove(e.imageId) - } else { - _selectedImageIds.add(e.imageId) + is CustomSelectorEvent.OnImageSelection -> { + _uiState.update { state -> + val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) { + state.selectedImageIds - e.imageId // Remove if already selected + } else{ + state.selectedImageIds + e.imageId // Add if not selected + } + state.copy(selectedImageIds = updatedSelectedIds) } } + is CustomSelectorEvent.OnDragImageSelection-> { + _uiState.update { it.copy(selectedImageIds = e.imageIds) } + } + else -> {} } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index 98167fe89..a984323b1 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -1,5 +1,8 @@ package fr.free.nrw.commons.customselector.ui.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -26,6 +29,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -41,30 +45,42 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toIntRect import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.rememberAsyncImagePainter +import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog +import fr.free.nrw.commons.ui.theme.CommonsTheme import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @OptIn(ExperimentalFoundationApi::class) @Composable fun ImagesPane( + uiState: CustomSelectorState, selectedFolder: Folder, - selectedImages: List, - imageList: List, - onNavigateBack: ()-> Unit, - onToggleImageSelection: (Long) -> Unit, - adaptiveInfo: WindowAdaptiveInfo + selectedImages: () -> Set, + onNavigateBack: () -> Unit, + onEvent: (CustomSelectorEvent) -> Unit, + adaptiveInfo: WindowAdaptiveInfo, + hasPartialAccess: Boolean = false ) { - val inSelectionMode by remember { derivedStateOf { selectedImages.isNotEmpty() } } +// val inSelectionMode by remember { derivedStateOf { selectedImages().isNotEmpty() } } val lazyGridState = rememberLazyGridState() var autoScrollSpeed by remember { mutableFloatStateOf(0f) } + val isCompatWidth by remember(adaptiveInfo.windowSizeClass) { + derivedStateOf { + adaptiveInfo.windowSizeClass + .windowWidthSizeClass == WindowWidthSizeClass.COMPACT + } + } LaunchedEffect(autoScrollSpeed) { if (autoScrollSpeed != 0f) { @@ -81,17 +97,35 @@ fun ImagesPane( primaryText = selectedFolder.bucketName, secondaryText = "${selectedFolder.itemsCount} images", onNavigateBack = onNavigateBack, - showNavigationIcon = adaptiveInfo.windowSizeClass - .windowWidthSizeClass == WindowWidthSizeClass.COMPACT + showNavigationIcon = isCompatWidth, + showAlertIcon = selectedImages().size > 20, + selectionCount = selectedImages().size, + showSelectionCount = uiState.inSelectionMode ) + }, + bottomBar = { + AnimatedVisibility( + visible = uiState.inSelectionMode && isCompatWidth, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + Surface(tonalElevation = 1.dp) { + CustomSelectorBottomBar( + onPrimaryAction = { /*TODO("Implement action to upload selected images")*/ }, + onSecondaryAction = { /*TODO("Implement action to mark/unmark as not for upload")*/ }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } } - ) { innerPadding-> + ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { - PartialStorageAccessDialog( - isVisible = true, - onManage = { /*TODO*/ }, - modifier = Modifier.padding(8.dp) - ) + if (hasPartialAccess) { + PartialStorageAccessDialog( + onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ }, + modifier = Modifier.padding(8.dp) + ) + } LazyVerticalGrid( columns = GridCells.Adaptive(116.dp), @@ -99,9 +133,11 @@ fun ImagesPane( .fillMaxSize() .imageGridDragHandler( gridState = lazyGridState, - imageList = imageList, - selectedImageIds = { selectedImages }, - onImageSelect = { onToggleImageSelection(it) }, + imageList = uiState.filteredImages, + selectedImageIds = selectedImages, + setSelectedImageIds = { + onEvent(CustomSelectorEvent.OnDragImageSelection(it)) + }, autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, setAutoScrollSpeed = { autoScrollSpeed = it } ), @@ -110,23 +146,23 @@ fun ImagesPane( horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { - items(imageList, key = { it.id }) { image-> + items(uiState.filteredImages, key = { it.id }) { image -> val isSelected by remember { - derivedStateOf { selectedImages.contains(image.id) } + derivedStateOf { selectedImages().contains(image.id) } } ImageItem( imagePainter = rememberAsyncImagePainter(model = image.uri), isSelected = isSelected, - inSelectionMode = inSelectionMode, + inSelectionMode = uiState.inSelectionMode, modifier = Modifier.combinedClickable( onClick = { - if(inSelectionMode) { - onToggleImageSelection(image.id) + if (uiState.inSelectionMode) { + onEvent(CustomSelectorEvent.OnImageSelection(image.id)) } }, onLongClick = { - onToggleImageSelection(image.id) + onEvent(CustomSelectorEvent.OnImageSelection(image.id)) } ) ) @@ -153,8 +189,8 @@ fun ImageItem( contentScale = ContentScale.Crop ) - if(inSelectionMode) { - if(isSelected) { + if (inSelectionMode) { + if (isSelected) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, @@ -181,14 +217,44 @@ fun ImageItem( } } +@Preview +@Composable +private fun ImageItemPreview() { + CommonsTheme { + Surface { + ImageItem( + imagePainter = painterResource(id = R.drawable.image_placeholder_96), + isSelected = false, + inSelectionMode = true, + modifier = Modifier + .padding(16.dp) + .size(116.dp) + ) + } + } +} + +/** + * A modifier that handles drag gestures on an image grid to allow for selecting multiple images. + * + * This modifier detects drag gestures and updates the selected images based on the drag position. + * It also handles auto-scrolling when the drag reaches the edges of the grid. + * + * @param gridState The state of the lazy grid. + * @param imageList The list of images displayed in the grid. + * @param selectedImageIds A function that returns the currently selected image IDs. + * @param autoScrollThreshold The distance from the edge of the grid at which auto-scrolling should start. + * @param setSelectedImageIds A callback function that is invoked when the selected images change. + * @param setAutoScrollSpeed A callback function that is invoked to set the auto-scroll speed. + */ fun Modifier.imageGridDragHandler( gridState: LazyGridState, imageList: List, - selectedImageIds:()-> List, + selectedImageIds: () -> Set, autoScrollThreshold: Float, - onImageSelect: (Long) -> Unit = { }, - setAutoScrollSpeed: (Float) -> Unit = { }, -) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, onImageSelect) { + setSelectedImageIds: (Set) -> Unit, + setAutoScrollSpeed: (Float) -> Unit, +) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, setSelectedImageIds, imageList) { fun imageIndexAtOffset(hitPoint: Offset): Int? = gridState.layoutInfo.visibleItemsInfo.find { itemInfo -> @@ -200,33 +266,56 @@ fun Modifier.imageGridDragHandler( var isSelecting = true detectDragGestures( - onDragStart = { offset-> + onDragStart = { offset -> imageIndexAtOffset(offset)?.let { val imageId = imageList[it].id - if(!selectedImageIds().contains(imageId)) { - dragStartIndex = it - currentDragIndex = it - onImageSelect(imageList[it].id) + dragStartIndex = it + currentDragIndex = it + + if (!selectedImageIds().contains(imageId)) { + isSelecting = true + setSelectedImageIds(selectedImageIds().plus(imageId)) + } else { + isSelecting = false + setSelectedImageIds(selectedImageIds().minus(imageId)) } } }, onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null }, onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null }, - onDrag = { change, _-> - dragStartIndex?.let { startIndex-> - currentDragIndex?.let { endIndex-> - val start = minOf(startIndex, endIndex) - val end = maxOf(start, endIndex) + onDrag = { change, _ -> + dragStartIndex?.let { startIndex -> + val distFromBottom = gridState.layoutInfo.viewportSize.height - change.position.y + val distFromTop = change.position.y + setAutoScrollSpeed( + when { + distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom + distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop) + else -> 0f + } + ) - (start..end).forEach { index-> - val imageId = imageList[index].id - val ifContains = selectedImageIds().contains(imageId) - if (isSelecting && !selectedImageIds().contains(imageId)) { - println("Selecting...") - println("contains: $ifContains") - onImageSelect(imageId) - } else if (!isSelecting && selectedImageIds().contains(imageId)) { - onImageSelect(imageId) + currentDragIndex?.let { currentIndex -> + imageIndexAtOffset(change.position)?.let { pointerIndex -> + if (currentIndex != pointerIndex) { + if (isSelecting) { + setSelectedImageIds( + selectedImageIds().minus( + imageList.getImageIdsInRange(startIndex, currentIndex) + ).plus( + imageList.getImageIdsInRange(startIndex, pointerIndex) + ) + ) + } else { + setSelectedImageIds( + selectedImageIds().plus( + imageList.getImageIdsInRange(currentIndex, pointerIndex) + ).minus( + imageList.getImageIdsInRange(startIndex, pointerIndex) + ) + ) + } + currentDragIndex = pointerIndex } } } @@ -235,26 +324,23 @@ fun Modifier.imageGridDragHandler( ) } -private fun Set.addUpTo( - initialKey: Int?, - pointerKey: Int? -): Set { - return if(initialKey == null || pointerKey == null) { - this +/** + * Calculates a set of image IDs within a given range of indices in a list of images. + * + * @param initialKey The starting index of the range. + * @param pointerKey The ending index of the range. + * @return A set of image IDs within the specified range. + */ +fun List.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set { + val setOfKeys = mutableSetOf() + if (initialKey < pointerKey) { + (initialKey..pointerKey).forEach { + setOfKeys.add(this[it].id) + } } else { - this.plus(initialKey..pointerKey) - .plus(pointerKey..initialKey) - } -} - -private fun Set.removeUpTo( - initialKey: Int?, - previousPointerKey: Int? -): Set { - return if(initialKey == null || previousPointerKey == null) { - this - } else { - this.minus(initialKey..previousPointerKey) - .minus(previousPointerKey..initialKey) + (pointerKey..initialKey).forEach { + setOfKeys.add(this[it].id) + } } + return setOfKeys } \ No newline at end of file