Add drag and tap gestures to select images

This commit is contained in:
Rohit Verma 2024-10-13 14:09:13 +05:30
parent ca30bf18bf
commit 55c09395e4
4 changed files with 174 additions and 80 deletions

View file

@ -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<Long>): CustomSelectorEvent
}

View file

@ -5,5 +5,9 @@ import fr.free.nrw.commons.customselector.model.Image
data class CustomSelectorState(
val isLoading: Boolean = false,
val folders: List<Folder> = emptyList(),
val filteredImages: List<Image> = emptyList()
)
val filteredImages: List<Image> = emptyList(),
val selectedImageIds: Set<Long> = emptySet()
) {
val inSelectionMode: Boolean
get() = selectedImageIds.isNotEmpty()
}

View file

@ -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<Long, MutableList<Image>>()
private var _selectedImageIds = mutableStateListOf<Long>()
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 -> {}
}
}

View file

@ -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<Long>,
imageList: List<Image>,
onNavigateBack: ()-> Unit,
onToggleImageSelection: (Long) -> Unit,
adaptiveInfo: WindowAdaptiveInfo
selectedImages: () -> Set<Long>,
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<Image>,
selectedImageIds:()-> List<Long>,
selectedImageIds: () -> Set<Long>,
autoScrollThreshold: Float,
onImageSelect: (Long) -> Unit = { },
setAutoScrollSpeed: (Float) -> Unit = { },
) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, onImageSelect) {
setSelectedImageIds: (Set<Long>) -> 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<Int>.addUpTo(
initialKey: Int?,
pointerKey: Int?
): Set<Int> {
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<Image>.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set<Long> {
val setOfKeys = mutableSetOf<Long>()
if (initialKey < pointerKey) {
(initialKey..pointerKey).forEach {
setOfKeys.add(this[it].id)
}
} else {
this.plus(initialKey..pointerKey)
.plus(pointerKey..initialKey)
}
}
private fun Set<Int>.removeUpTo(
initialKey: Int?,
previousPointerKey: Int?
): Set<Int> {
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
}