Seamless Play of D&D — Implementing Drag and Drop Across Multiple Screens in Your Android App with Jetpack Compose | by Nirbhay Pherwani | Aug, 2023

Table of Contents

The LongPressDraggable composable function is the foundation of our seamless D&D experience. It allows us to wrap any content (ex. horizontal pager) with dragging behavior, making it draggable upon a long-press gesture.

Code

@Composable
fun LongPressDraggable(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit
) {

val state = remember DragTargetInfo()

CompositionLocalProvider(
LocalDragTargetInfo provides state
) {
Box(modifier = modifier.fillMaxSize())

content()
if (state.isDragging == true)
var targetSize by remember
mutableStateOf(IntSize.Zero)

Box(modifier = Modifier
.graphicsLayer
val offset = (state.dragPosition + state.dragOffset)
// will scale the dragged item being dragged by 50%
scaleX = 1.5f
scaleY = 1.5f
// adds a bit of transparency
alpha = if (targetSize == IntSize.Zero) 0f else .9f
// horizontal displacement
translationX = offset.x.minus(targetSize.width / 2)
// vertical displacement
translationY = offset.y.minus(targetSize.height / 2)

.onGloballyPositioned
targetSize = it.size
it.let coordinates ->
state.absolutePositionX = coordinates.positionInRoot().x
state.absolutePositionY = coordinates.positionInRoot().y


)
state.draggableComposable?.invoke()



}
}

Usage

We leverage the LongPressDraggable function to make the entire horizontal pager and items draggable, providing users with the flexibility to move items to and from individual screens easily.

@Composable
fun HorizontalPagerContent()
val pagerState = rememberPagerState()

// Wrap the entire horizontal pager with LongPressDraggable
LongPressDraggable
HorizontalPager(state = pagerState, count = 2) pageIndex ->
when (pageIndex)
0 -> Page1Content()
1 -> Page2Content()



The DragTarget composable function plays a pivotal role in defining drag sources, representing items that can be dragged.

Code

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> DragTarget(
context: Context,
pagerSize: Int,
verticalPagerState: PagerState? = null, // if you have nested / multi paged app
horizontalPagerState: PagerState? = null,
modifier: Modifier,
dataToDrop: Any? = null, // change type here to your data model class
content: @Composable (shouldAnimate: Boolean) -> Unit
) {
val coroutineScope = rememberCoroutineScope()

var currentPosition by remember mutableStateOf(Offset.Zero)
val currentState = LocalDragTargetInfo.current

Box(modifier = modifier
.onGloballyPositioned
currentPosition = it.localToWindow(Offset.Zero)

.pointerInput(Unit) {
detectDragGesturesAfterLongPress(onDragStart =

currentState.dataToDrop = dataToDrop
currentState.isDragging = true
currentState.dragPosition = currentPosition + it
currentState.draggableComposable = content(false) // render scaled item without animation

, onDrag = { change, dragAmount ->
change.consume()

currentState.itemDropped =
false // used to prevent drop target from multiple re-renders

currentState.dragOffset += Offset(dragAmount.x, dragAmount.y)

val xOffset = abs(currentState.dragOffset.x)
val yOffset = abs(currentState.dragOffset.y)

coroutineScope.launch {

// this is a flag only for demo purposes, change as per your needs
val boundDragEnabled = false

if(boundDragEnabled)
// use this for dragging after the user has dragged the item outside a bound around the original item itself
if (xOffset > 20 && yOffset > 20)
verticalPagerState?.animateScrollToPage(
1,
animationSpec = tween(
durationMillis = 300,
easing = androidx.compose.animation.core.EaseOutCirc
)
)

else
// for dragging to and fro from different pages in the pager
val currentPage = horizontalPagerState?.currentPage
val dragPositionX = currentState.dragPosition.x + currentState.dragOffset.x
val dragPositionY = currentState.dragPosition.y + currentState.dragOffset.y

val displayMetrics = context.resources.displayMetrics

// if item is very close to left edge of page, move to previous page
if (dragPositionX < 60)
currentPage?.let
if (it > 1)
horizontalPagerState.animateScrollToPage(currentPage - 1)


else if (displayMetrics.widthPixels - dragPositionX < 60)
// if item is very close to right edge of page, move to next page
currentPage?.let
if (it < pagerSize)
horizontalPagerState.animateScrollToPage(currentPage + 1)




}

}, onDragEnd =
currentState.isDragging = false
currentState.dragOffset = Offset.Zero
, onDragCancel =
currentState.isDragging = false
currentState.dragOffset = Offset.Zero
)
}, contentAlignment = Alignment.Center
)
content(true) // render positioned content with animation

}

The DragTarget composable function is a fundamental building block for enabling drag and drop interactions.

Let’s break down its key components —

  1. context — The Context parameter is used to access the application context. It is required to retrieve resources like display metrics, which can be useful in calculating dragging behavior.
  2. verticalPagerState and horizontalPagerState — These optional PagerState parameters represent the state of the vertical and horizontal pagers, respectively. They are used to control the scroll positions and animate scrolling during the drag and drop operation.
  3. modifier — The modifier parameter is used to detect gestures and update the current drag state.
  4. dataToDrop — The Any? parameter represents the data that will be dropped during the drag operation. It allows you to associate specific data with the draggable item being moved.
  5. content— The @Composable (shouldAnimate: Boolean) -> Unit parameter represents the content of the DragTarget. It’s a composable function that defines the UI elements to be displayed within the DragTarget based on the state. Additionally, shouldAnimate helps the lambda block decide if the content should be animated or not while rendering. Example, one might not want the scaled composable to have animation.

How It Works —

  1. Local State and Event Handling: The DragTarget uses LocalDragTargetInfo.current to access the current state of the drag and drop interactions. It also uses the pointerInput modifier to handle drag gestures and respond to user interactions.
  2. Initial Configuration: When the DragTarget is first created, it calculates and stores the initial position (currentPosition) of the DragTarget in the layout using onGloballyPositioned.
  3. Drag Gesture Detection: The detectDragGesturesAfterLongPress function is used to detect drag gestures after a long-press is initiated on the DragTarget. When the drag starts (onDragStart), it sets the isDragging flag to true and captures the initial drag position (dragPosition) relative to the window’s coordinates.
  4. Dragging Update: During the drag operation (onDrag), the onDrag lambda is continuously called as the user moves their finger. It updates the dragOffset, representing the current displacement from the initial drag position. The lambda skillfully manages various scenarios, such as detecting when the drag is beyond a specified boundary or reaches the ends of a page, elegantly initiating a smooth move to another page.. Feel free to change that block of code as per your needs.
  5. End and Cancellation: When the drag ends (onDragEnd) or is canceled (onDragCancel), the isDragging flag is reset, and the dragOffset is reset to Offset.Zero.

Usage

@Composable
fun DragTargetWidgetItem(
data: Widget,
pagerState: PagerState
)
DragTarget(
context = LocalContext.current,
pagerSize = 2, // Assuming there are two pages in the horizontal pager
horizontalPagerState = pagerState,
modifier = modifier.wrapContentSize(),
dataToDrop = data,
) shouldAnimate ->
WidgetItem(data, shouldAnimate)

@Composable
fun WidgetItem(
data: Widget,
shouldAnimate: Boolean
)
// Add your custom implementation for the WidgetItem here.
// This composable will render the content of the draggable widget.
// You can use the 'data' parameter to extract necessary information and display it.
// The 'shouldAnimate' parameter can be used to control animations if needed.

// Example: Displaying a simple card with the widget's name
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.graphicsLayer
// Scale the card when shouldAnimate is true
scaleX = if (shouldAnimate) 1.2f else 1.0f
scaleY = if (shouldAnimate) 1.2f else 1.0f
,
elevation = 4.dp
)
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
Text(
text = data.widgetName,
style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = data.widgetDescription)


By