mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-27 12:53:55 +01:00
Add drag and tap gestures to select images
This commit is contained in:
parent
ca30bf18bf
commit
55c09395e4
4 changed files with 174 additions and 80 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue