Skip to content

Instantly share code, notes, and snippets.

@tkuenneth
Created December 13, 2024 15:10
Show Gist options
  • Save tkuenneth/87cc0c1322b3dd69e09ab2dfbadf403c to your computer and use it in GitHub Desktop.
Save tkuenneth/87cc0c1322b3dd69e09ab2dfbadf403c to your computer and use it in GitHub Desktop.
This demo shows how to achieve automatic scrolling of pages in a HorizontalPager upon drag and drop operations of children of pages.
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.toIntRect
import androidx.compose.ui.unit.toSize
import kotlinx.coroutines.delay
import kotlin.math.max
import kotlin.math.min
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DragAndDropDemo() {
val numbers = remember { mutableStateListOf(*((1..16).toList().toTypedArray())) }
val itemsPerPage = 4
val pagerState = rememberPagerState { (numbers.size + itemsPerPage - 1) / itemsPerPage }
val gridStates: List<LazyGridState> = rememberSaveable {
Array<LazyGridState>(pagerState.pageCount) { LazyGridState(0, 0) }.toList()
}
var dragStartIndex by remember { mutableIntStateOf(-1) }
var initialOffsetX by remember { mutableFloatStateOf(0f) }
var initialOffsetY by remember { mutableFloatStateOf(0f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
var scrollDirection by remember { mutableIntStateOf(0) }
var ghostSize by remember { mutableStateOf(DpSize.Zero) }
val updateVars: (Float, Float, Float, Float, Int, Int) -> Unit = { x1, y1, x2, y2, sI, sD ->
dragStartIndex = sI
initialOffsetX = x1
initialOffsetY = y1
offsetX = x2
offsetY = y2
scrollDirection = sD
}
val density = LocalDensity.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return available
}
}
}
PageScroller(scrollDirection, dragStartIndex, pagerState)
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(state = pagerState,
pageNestedScrollConnection = nestedScrollConnection,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(onDragStart = { offset ->
gridStates[pagerState.currentPage]
.gridItemKeyAtPosition(offset)
.let { key ->
ghostSize = gridItemSize(gridStates, pagerState, key)
with(density) {
updateVars(
ghostSize.width.toPx() / 2,
ghostSize.height.toPx() / 2,
offset.x,
offset.y,
key,
0
)
}
}
}, onDragEnd = {
val offset = Offset(offsetX, offsetY)
gridStates[pagerState.currentPage]
.gridItemKeyAtPosition(offset)
.let { dragEndIndex ->
if (dragEndIndex != -1) {
val current = numbers[dragEndIndex]
numbers[dragEndIndex] = numbers[dragStartIndex]
numbers[dragStartIndex] = current
}
}
updateVars(0F, 0F, 0F, 0F, -1, 0)
}, onDragCancel = {
updateVars(0F, 0F, 0F, 0F, -1, 0)
}, onDrag = { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
with(gridStates[pagerState.currentPage].layoutInfo) {
val distFromLeft = change.position.x
val distFromRight = viewportSize.width - change.position.x
val threshold = ghostSize.width / 2
with(density) {
scrollDirection = if (distFromLeft.toDp() < threshold) {
-1
} else {
if (distFromRight.toDp() < threshold) {
1
} else {
0
}
}
}
}
})
}) { page ->
Page(
itemsPerPage = itemsPerPage,
page = page,
numbers = numbers,
gridState = gridStates[page]
)
}
if (dragStartIndex != -1) {
with(density) {
Surface(
modifier = Modifier
.size(ghostSize)
.offset(
offsetX.toDp() - initialOffsetX.toDp(),
offsetY.toDp() - initialOffsetY.toDp()
), shadowElevation = 4.dp
) {
BoxForPage(number = numbers[dragStartIndex], modifier = Modifier.fillMaxSize())
}
}
}
}
}
@Composable
private fun PageScroller(
scrollDirection: Int, dragStartIndex: Int, pagerState: PagerState
) {
LaunchedEffect(scrollDirection) {
while (scrollDirection != 0) {
delay(600)
if (dragStartIndex != -1) {
when (scrollDirection) {
-1 -> {
pagerState.animateScrollToPage(max(0, pagerState.currentPage - 1))
}
1 -> {
pagerState.animateScrollToPage(
min(
pagerState.pageCount - 1, pagerState.currentPage + 1
)
)
}
}
}
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun Page(
gridState: LazyGridState, itemsPerPage: Int, page: Int, numbers: SnapshotStateList<Int>
) {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(itemsPerPage / 2),
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val from = page * itemsPerPage
val to = (page + 1) * itemsPerPage
(from until to).forEach { index ->
item(key = index) {
BoxForPage(
modifier = Modifier.aspectRatio(1F),
number = if (index < numbers.size) numbers[index] else -1
)
}
}
}
}
@Composable
fun BoxForPage(modifier: Modifier = Modifier, number: Int = -1) {
Box(
modifier = Modifier
.background(if (number == -1) Color.Transparent else MaterialTheme.colorScheme.secondaryContainer)
.then(modifier), contentAlignment = Alignment.Center
) {
if (number != -1) {
Text(
text = number.toString(),
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
private fun Density.gridItemSize(
gridStates: List<LazyGridState>, pagerState: PagerState, dragStartIndex: Int
): DpSize =
(gridStates[pagerState.currentPage].layoutInfo.visibleItemsInfo.find { it.key == dragStartIndex }?.size
?: IntSize.Zero).toSize().toDpSize()
private fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): Int =
layoutInfo.visibleItemsInfo.find { itemInfo ->
itemInfo.size.toIntRect().contains(hitPoint.round() - itemInfo.offset)
}?.key as? Int ?: -1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment