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 { interface CustomSelectorEvent {
data class OnFolderClick(val bucketId: Long): 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( data class CustomSelectorState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val folders: List<Folder> = emptyList(), 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 package fr.free.nrw.commons.customselector.ui.screens
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import fr.free.nrw.commons.customselector.data.MediaReader 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 val foldersMap = mutableMapOf<Long, MutableList<Image>>()
private var _selectedImageIds = mutableStateListOf<Long>()
val selectedImageIds = _selectedImageIds
init { init {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {
@ -46,12 +42,19 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel()
} }
} }
is CustomSelectorEvent.OnImageSelect -> { is CustomSelectorEvent.OnImageSelection -> {
if(_selectedImageIds.contains(e.imageId)) { _uiState.update { state ->
_selectedImageIds.remove(e.imageId) val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) {
} else { state.selectedImageIds - e.imageId // Remove if already selected
_selectedImageIds.add(e.imageId) } else{
state.selectedImageIds + e.imageId // Add if not selected
} }
state.copy(selectedImageIds = updatedSelectedIds)
}
}
is CustomSelectorEvent.OnDragImageSelection-> {
_uiState.update { it.copy(selectedImageIds = e.imageIds) }
} }
else -> {} else -> {}

View file

@ -1,5 +1,8 @@
package fr.free.nrw.commons.customselector.ui.screens 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.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity 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.dp
import androidx.compose.ui.unit.round import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.toIntRect import androidx.compose.ui.unit.toIntRect
import androidx.window.core.layout.WindowWidthSizeClass import androidx.window.core.layout.WindowWidthSizeClass
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.model.Image 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.CustomSelectorTopBar
import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
import fr.free.nrw.commons.ui.theme.CommonsTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ImagesPane( fun ImagesPane(
uiState: CustomSelectorState,
selectedFolder: Folder, selectedFolder: Folder,
selectedImages: List<Long>, selectedImages: () -> Set<Long>,
imageList: List<Image>, onNavigateBack: () -> Unit,
onNavigateBack: ()-> Unit, onEvent: (CustomSelectorEvent) -> Unit,
onToggleImageSelection: (Long) -> Unit, adaptiveInfo: WindowAdaptiveInfo,
adaptiveInfo: WindowAdaptiveInfo hasPartialAccess: Boolean = false
) { ) {
val inSelectionMode by remember { derivedStateOf { selectedImages.isNotEmpty() } } // val inSelectionMode by remember { derivedStateOf { selectedImages().isNotEmpty() } }
val lazyGridState = rememberLazyGridState() val lazyGridState = rememberLazyGridState()
var autoScrollSpeed by remember { mutableFloatStateOf(0f) } var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
val isCompatWidth by remember(adaptiveInfo.windowSizeClass) {
derivedStateOf {
adaptiveInfo.windowSizeClass
.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
}
}
LaunchedEffect(autoScrollSpeed) { LaunchedEffect(autoScrollSpeed) {
if (autoScrollSpeed != 0f) { if (autoScrollSpeed != 0f) {
@ -81,17 +97,35 @@ fun ImagesPane(
primaryText = selectedFolder.bucketName, primaryText = selectedFolder.bucketName,
secondaryText = "${selectedFolder.itemsCount} images", secondaryText = "${selectedFolder.itemsCount} images",
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
showNavigationIcon = adaptiveInfo.windowSizeClass showNavigationIcon = isCompatWidth,
.windowWidthSizeClass == WindowWidthSizeClass.COMPACT 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)) { Column(modifier = Modifier.padding(innerPadding)) {
if (hasPartialAccess) {
PartialStorageAccessDialog( PartialStorageAccessDialog(
isVisible = true, onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ },
onManage = { /*TODO*/ },
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
}
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(116.dp), columns = GridCells.Adaptive(116.dp),
@ -99,9 +133,11 @@ fun ImagesPane(
.fillMaxSize() .fillMaxSize()
.imageGridDragHandler( .imageGridDragHandler(
gridState = lazyGridState, gridState = lazyGridState,
imageList = imageList, imageList = uiState.filteredImages,
selectedImageIds = { selectedImages }, selectedImageIds = selectedImages,
onImageSelect = { onToggleImageSelection(it) }, setSelectedImageIds = {
onEvent(CustomSelectorEvent.OnDragImageSelection(it))
},
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() },
setAutoScrollSpeed = { autoScrollSpeed = it } setAutoScrollSpeed = { autoScrollSpeed = it }
), ),
@ -110,23 +146,23 @@ fun ImagesPane(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(8.dp) contentPadding = PaddingValues(8.dp)
) { ) {
items(imageList, key = { it.id }) { image-> items(uiState.filteredImages, key = { it.id }) { image ->
val isSelected by remember { val isSelected by remember {
derivedStateOf { selectedImages.contains(image.id) } derivedStateOf { selectedImages().contains(image.id) }
} }
ImageItem( ImageItem(
imagePainter = rememberAsyncImagePainter(model = image.uri), imagePainter = rememberAsyncImagePainter(model = image.uri),
isSelected = isSelected, isSelected = isSelected,
inSelectionMode = inSelectionMode, inSelectionMode = uiState.inSelectionMode,
modifier = Modifier.combinedClickable( modifier = Modifier.combinedClickable(
onClick = { onClick = {
if(inSelectionMode) { if (uiState.inSelectionMode) {
onToggleImageSelection(image.id) onEvent(CustomSelectorEvent.OnImageSelection(image.id))
} }
}, },
onLongClick = { onLongClick = {
onToggleImageSelection(image.id) onEvent(CustomSelectorEvent.OnImageSelection(image.id))
} }
) )
) )
@ -153,8 +189,8 @@ fun ImageItem(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
if(inSelectionMode) { if (inSelectionMode) {
if(isSelected) { if (isSelected) {
Icon( Icon(
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = null, 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( fun Modifier.imageGridDragHandler(
gridState: LazyGridState, gridState: LazyGridState,
imageList: List<Image>, imageList: List<Image>,
selectedImageIds:()-> List<Long>, selectedImageIds: () -> Set<Long>,
autoScrollThreshold: Float, autoScrollThreshold: Float,
onImageSelect: (Long) -> Unit = { }, setSelectedImageIds: (Set<Long>) -> Unit,
setAutoScrollSpeed: (Float) -> Unit = { }, setAutoScrollSpeed: (Float) -> Unit,
) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, onImageSelect) { ) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, setSelectedImageIds, imageList) {
fun imageIndexAtOffset(hitPoint: Offset): Int? = fun imageIndexAtOffset(hitPoint: Offset): Int? =
gridState.layoutInfo.visibleItemsInfo.find { itemInfo -> gridState.layoutInfo.visibleItemsInfo.find { itemInfo ->
@ -200,33 +266,56 @@ fun Modifier.imageGridDragHandler(
var isSelecting = true var isSelecting = true
detectDragGestures( detectDragGestures(
onDragStart = { offset-> onDragStart = { offset ->
imageIndexAtOffset(offset)?.let { imageIndexAtOffset(offset)?.let {
val imageId = imageList[it].id val imageId = imageList[it].id
if(!selectedImageIds().contains(imageId)) {
dragStartIndex = it dragStartIndex = it
currentDragIndex = it currentDragIndex = it
onImageSelect(imageList[it].id)
if (!selectedImageIds().contains(imageId)) {
isSelecting = true
setSelectedImageIds(selectedImageIds().plus(imageId))
} else {
isSelecting = false
setSelectedImageIds(selectedImageIds().minus(imageId))
} }
} }
}, },
onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null }, onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null },
onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null }, onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null },
onDrag = { change, _-> onDrag = { change, _ ->
dragStartIndex?.let { startIndex-> dragStartIndex?.let { startIndex ->
currentDragIndex?.let { endIndex-> val distFromBottom = gridState.layoutInfo.viewportSize.height - change.position.y
val start = minOf(startIndex, endIndex) val distFromTop = change.position.y
val end = maxOf(start, endIndex) setAutoScrollSpeed(
when {
distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom
distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop)
else -> 0f
}
)
(start..end).forEach { index-> currentDragIndex?.let { currentIndex ->
val imageId = imageList[index].id imageIndexAtOffset(change.position)?.let { pointerIndex ->
val ifContains = selectedImageIds().contains(imageId) if (currentIndex != pointerIndex) {
if (isSelecting && !selectedImageIds().contains(imageId)) { if (isSelecting) {
println("Selecting...") setSelectedImageIds(
println("contains: $ifContains") selectedImageIds().minus(
onImageSelect(imageId) imageList.getImageIdsInRange(startIndex, currentIndex)
} else if (!isSelecting && selectedImageIds().contains(imageId)) { ).plus(
onImageSelect(imageId) 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?, * Calculates a set of image IDs within a given range of indices in a list of images.
pointerKey: Int? *
): Set<Int> { * @param initialKey The starting index of the range.
return if(initialKey == null || pointerKey == null) { * @param pointerKey The ending index of the range.
this * @return A set of image IDs within the specified range.
} else { */
this.plus(initialKey..pointerKey) fun List<Image>.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set<Long> {
.plus(pointerKey..initialKey) val setOfKeys = mutableSetOf<Long>()
if (initialKey < pointerKey) {
(initialKey..pointerKey).forEach {
setOfKeys.add(this[it].id)
} }
}
private fun Set<Int>.removeUpTo(
initialKey: Int?,
previousPointerKey: Int?
): Set<Int> {
return if(initialKey == null || previousPointerKey == null) {
this
} else { } else {
this.minus(initialKey..previousPointerKey) (pointerKey..initialKey).forEach {
.minus(previousPointerKey..initialKey) setOfKeys.add(this[it].id)
} }
}
return setOfKeys
} }