All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.dragselectcompose.core.GridDragSelect.kt Maven / Gradle / Ivy

Go to download

A Compose multiplatform library for adding Google Photos style drag-to-select multi-selection to a LazyGrid.

The newest version!
package com.dragselectcompose.core

import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.toIntRect
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive

/**
 * A [Modifier] that enables drag selection for a [LazyGridState].
 *
 * Example usage:
 *
 * ```
 * val items: List = (0..100).map { it }
 *
 * LazyVerticalGrid(
 *     state = dragSelectState.lazyGridState,
 *     columns = GridCells.Adaptive(minSize = 128.dp),
 *     verticalArrangement = Arrangement.spacedBy(3.dp),
 *     horizontalArrangement = Arrangement.spacedBy(3.dp),
 *     modifier = modifier.gridDragSelect(
 *         items = items,
 *         state = dragSelectState,
 *     ),
 * ) {
 *    // Build your gird
 * }
 * ```
 *
 * @param[Item] The type of item in the grid.
 * @param[items] The list of items in the grid.
 * @param[state] The [DragSelectState] for maintaining state
 * @param[enableAutoScroll] Whether to enable auto-scrolling when dragging near the edge of the grid.
 * @param[autoScrollThreshold] The distance from the edge of the grid to start auto-scrolling.
 * @param[enableHaptics] Whether to enable haptic feedback when dragging.
 * @param[hapticFeedback] The [HapticFeedback] to use, defaults to [GridDragSelectDefaults.hapticsFeedback].
 * @return A [Modifier] that enables drag selection for a [LazyGridState].
 */
public fun  Modifier.gridDragSelect(
    items: List,
    state: DragSelectState,
    enableAutoScroll: Boolean = true,
    autoScrollThreshold: Float? = null,
    enableHaptics: Boolean = true,
    hapticFeedback: HapticFeedback? = null,
): Modifier = composed {
    val scrollThreshold: Float = autoScrollThreshold ?: GridDragSelectDefaults.autoScrollThreshold
    if (enableAutoScroll) {
        LaunchedEffect(state.autoScrollSpeed.value) {
            if (state.autoScrollSpeed.value == 0f) return@LaunchedEffect

            while (isActive) {
                state.gridState.scrollBy(state.autoScrollSpeed.value)
                delay(10)
            }
        }
    }

    val haptics: HapticFeedback? =
        if (!enableHaptics) null
        else hapticFeedback ?: GridDragSelectDefaults.hapticsFeedback

    val currentItems by rememberUpdatedState(items)

    pointerInput(Unit) {
        detectDragGesturesAfterLongPress(
            onDragStart = { offset ->
                state.gridState.itemIndexAtPosition(offset)?.let { startIndex ->
                    val item = currentItems.getOrNull(startIndex)
                    if (item != null && state.selected.contains(item).not()) {
                        haptics?.performHapticFeedback(HapticFeedbackType.LongPress)
                        state.startDrag(item, startIndex)
                    }
                }
            },
            onDragCancel = state::stopDrag,
            onDragEnd = state::stopDrag,
            onDrag = { change, _ ->
                state.whenDragging { dragState ->
                    autoScrollSpeed.value = gridState.calculateScrollSpeed(change, scrollThreshold)

                    val itemPosition = gridState.getItemPosition(change.position)
                        ?: return@whenDragging

                    val newSelection =
                        currentItems.getSelectedItems(itemPosition, dragState, state::isSelected)

                    updateDrag(current = itemPosition)
                    updateSelected(newSelection)
                }
            },
        )
    }
}

/**
 * Calculates the auto-scroll speed based on the current drag position.
 *
 * @param[change] The [PointerInputChange] for the current drag position.
 * @param[scrollThreshold] The distance from the edge of the grid to start auto-scrolling.
 * @return The auto-scroll speed.
 */
private fun LazyGridState.calculateScrollSpeed(
    change: PointerInputChange,
    scrollThreshold: Float,
): Float {
    val distanceFromTop: Float = change.position.y
    val distanceFromBottom: Float = layoutInfo.viewportSize.height - distanceFromTop

    return when {
        distanceFromBottom < scrollThreshold -> scrollThreshold - distanceFromBottom
        distanceFromTop < scrollThreshold -> -(scrollThreshold - distanceFromTop)
        else -> 0f
    }
}

/**
 * Gets the index of the item that was hit by the drag.
 *
 * @param[hitPoint] The point where the drag hit the grid.
 * @return The index of the item that was hit by the drag.
 */
private fun LazyGridState.itemIndexAtPosition(hitPoint: Offset): Int? {
    val found = layoutInfo.visibleItemsInfo.find { itemInfo ->
        itemInfo.size.toIntRect().contains(hitPoint.round() - itemInfo.offset)
    }

    return found?.index
}

/**
 * Gets the index of the item that was hit by the drag.
 *
 * @param[hitPoint] The point where the drag hit the grid.
 * @return The index of the item that was hit by the drag, or the index of the last item if the
 * drag has gone past the last item.
 */
private fun LazyGridState.getItemPosition(hitPoint: Offset): Int? {
    return itemIndexAtPosition(hitPoint)
        ?: if (isPastLastItem(hitPoint)) layoutInfo.totalItemsCount - 1 else null
}

/**
 * Determines if the drag has gone past the last item in the list.
 *
 * @param[hitPoint] The point where the drag hit the grid.
 * @return True if the drag has gone past the last item in the list, false otherwise.
 */
private fun LazyGridState.isPastLastItem(hitPoint: Offset): Boolean {
    // Get the last item in the list
    val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
        ?.takeIf { it.index == layoutInfo.totalItemsCount - 1 }
        ?: return false

    // Determine if we have dragged past the last item in the list
    return hitPoint.y > lastItem.offset.y
}

/**
 * Gets a list of items that are selected based on the current drag state.
 *
 * @receiver The list of items in the grid.
 * @param[itemPosition] The position of the item that was hit by the drag.
 * @param[dragState] The current drag state.
 * @param[isSelected] A function to determine if an item is selected.
 * @return A list of items that are selected based on the current drag state.
 */
private fun  List.getSelectedItems(
    itemPosition: Int,
    dragState: DragState,
    isSelected: (Item) -> Boolean,
): List {
    val (initial, current) = dragState.run { initial to current }
    return filterIndexed { index, item ->
        // Determine if the item is within the drag range
        val withinRange = index in initial..itemPosition || index in itemPosition..initial

        // Determine if the item was previously selected and is still within the drag range
        val selected = isSelected(item)
        val previouslySelectedInRange =
            selected && index !in initial..current && index !in current..initial

        withinRange || previouslySelectedInRange
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy