Blog Infos
Author
Published
Topics
Published

A Guide to Creating Dynamic and Intuitive Drag and Drop Interactions for Your Android App’s Multi-Screen Experience

Multi Screen Drag and Drop Experience — Game Play (GIF) sourced from Wordy Night

 

Greetings, brave developers! Welcome to a world of magic and wonder, where dragons roam, and epic adventures await — well, not exactly! In this guide, we won’t be rolling dice or slaying monsters in the realm of Dungeons and Dragons. But hey, who says we can’t have a little fun while we master the art of drag and drop?

In this article, we will explore how to implement drag and drop functionality across multiple screens in your Android app using Jetpack Compose. Unlike a simple reorderable list, the example we use will involve dragging items between different pages, allowing users to move items (any data) from any source to any destination screen. Will be explaining each function’s usage, importance, and provide relevant examples to help you easily understand how to enable this powerful drag and drop interaction in your app.

So, buckle up and ready your keyboards as we dive into the enchanting world of drag and drop with Jetpack Compose.

What Are We Building?

Our goal is to implement a seamless play of D&D, enabling users to intuitively move data between different screens.

The example we’ll be building involves two screens, each featuring draggable widgets representing various data types. Users will have the freedom to drag widgets from one page to another, facilitating effortless data transfer. To enhance the user experience, we will integrate drag targets (widgets) and drop targets (lists) within each page, providing visual cues / feedback and allowing for dynamic data placement.

Let’s dive in and explore how to create this captivating multi-screen experience.

Defining the DragState

The DragTargetInfo class is a class used to store the state information related to drag and drop interactions. It holds various variables that keep track of the current drag state and provide essential data for handling drag and drop operations within the app.

Let’s go through each variable inside the DragTargetInfo class —

internal class DragTargetInfo {
    var isDragging: Boolean? by mutableStateOf(false)
    var dragPosition by mutableStateOf(Offset.Zero)
    var dragOffset by mutableStateOf(Offset.Zero)
    var draggableComposable by mutableStateOf<(@Composable () -> Unit)?>(null)
    var dataToDrop by mutableStateOf<Any?>(null)
    var itemDropped: Boolean by mutableStateOf(false)
    var absolutePositionX: Float by mutableStateOf(0F)
    var absolutePositionY: Float by mutableStateOf(0F)
}
  1. isDragging— It represents the current state of dragging. When isDragging is true, it indicates that a drag operation is in progress. When it is false or null, no drag operation is active.
  2. dragPosition — This variable of type Offset keeps track of the starting position of the drag operation. It stores the initial position where the drag gesture was initiated.
  3. dragOffset — This variable of type Offset tracks the offset by which the dragged item has been moved from its initial position (dragPosition). It gets updated continuously during the drag operation to reflect the current offset from the starting point.
  4. draggableComposable — This variable is of type @Composable () -> Unit, which represents a composable function that can be used to render the draggable item. When the drag operation starts, this function is set to render the draggable item.
  5. dataToDrop— This variable of type Any? is used to store the data that will be dropped during the drag operation. It holds the data associated with the draggable item being moved. You can change this to support a specific type.
  6. itemDropped — This variable of type Boolean is used to track whether the item has been successfully dropped. It helps prevent multiple re-renders of the drop target.
  7. absolutePositionX and absolutePositionY — These variables of type Float store the absolute X and Y positions of the drag target. These values are updated when the DragTarget is positioned in the layout.

These variables collectively provide the necessary state information and data required to manage the drag and drop interactions in the app. They help in tracking the current state of the drag operation, the position of the draggable item, the data to be dropped, and other relevant details. The DragTargetInfo class acts as a central state holder for drag and drop operations, making it easier to manage and control the interactions seamlessly.

Implementing LongPressDraggable

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()
            }
        }
    }
}
Drag Targets

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 
    }
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Jetpack Compose: Drawing without pain and recomposition

This is a talk on recomposition in Jetpack Compose and the myths of too many calls it is followed by. I’ll briefly explain the reasons behind recompositions and why they are not as problematic as…
Watch Video

Jetpack Compose: Drawing without pain and recomposition

Vitalii Markus
Android Engineer
Flo Health Inc.

Jetpack Compose: Drawing without pain and recomposition

Vitalii Markus
Android Engineer
Flo Health Inc.

Jetpack Compose: Drawing without pain and recomposition

Vitalii Markus
Android Engineer
Flo Health Inc.

Jobs

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)
        }
    }
}
Drop Target

The DropTarget composable serves as a drop zone, allowing items to be dropped onto it during a drag and drop operation. It is the counterpart to the DragTarget, representing the area where a dragged item can be accepted.

The DropTarget composable tracks the current drag position and offset and determines whether the dragged item is within the bounds of the drop target. Based on this information, it informs the content composable whether it’s currently a valid drop target and provides the associated data (if any) for the dragged item.

@Composable
fun <T> DropTarget(
    modifier: Modifier,
    content: @Composable() (BoxScope.(isInBound: Boolean, data: T?) -> Unit)
) {
    val dragInfo = LocalDragTargetInfo.current
    val dragPosition = dragInfo.dragPosition
    val dragOffset = dragInfo.dragOffset
    var isCurrentDropTarget by remember {
        mutableStateOf(false)
    }

    Box(
        modifier = modifier
            .onGloballyPositioned {
                it.boundsInWindow().let { rect ->
                    isCurrentDropTarget = rect.contains(dragPosition + dragOffset)
                }
            }
    ) {
        val data = if (isCurrentDropTarget && dragInfo.isDragging == false) dragInfo.dataToDrop as T? else null
        content(isCurrentDropTarget, data)
    }
}

How It Works —

  1. Local State and Event Handling: The DropTarget uses LocalDragTargetInfo.current to access the current state of the drag and drop interactions, including the drag position and offset.
  2. Bounds Detection: The onGloballyPositioned modifier is used to detect the position and bounds of the drop target in the layout. It calculates whether the drag position + offset is within the bounds of the drop target using contains. If so, it sets the isCurrentDropTarget state to true.
  3. Content Handling: Inside the DropTarget, we use the isCurrentDropTarget and the associated data (if available) from the DragTargetInfo to render the content. The content composable receives two parameters: isInBound (whether the dragged item is within the drop target bounds) and data (associated data of the dragged item).
Usage
@Composable
fun Page1Content(pagerState: PagerState) {
    val widgetList = viewModel.widgetList.collectAsState()
    
    DropTarget<Widget>(modifier = Modifier.fillMaxSize()) 
      { isInBound, droppedWidget ->
        if (!LocalDragTargetInfo.current.itemDropped) {
            if (isInBound) {
                droppedWidget?.let { widget ->
                    LocalDragTargetInfo.current.itemDropped = true
                    LocalDragTargetInfo.current.dataToDrop = null

                    val currentlyPlacedItem = getCurrentlyPlacedItemInList() 
                    // Use pagerState, LocalDragTargetInfo.current.absolutePositionX, 
                    // LocalDragTargetInfo.current.absolutePositionY to determine what's 
                    // currently placed in the list and make changes to the list accordingly

                    // Example: If nothing is currently placed at the drop position, add the dropped widget to the list
                    if (currentlyPlacedItem == null) {
                        addWidgetToList(widget)
                    } else {
                        // Example: Swap the currently placed item with the dropped widget
                        moveWidgets(widget, currentlyPlacedItem)
                    }
                }
            }
        }
    }
    WidgetsList(pagerState, widgetList)
}

// Implement the required functions to handle adding and swapping widgets in the list
fun addWidgetToList(widget: Widget) {
    // Add the dropped widget to the list
}

fun moveWidgets(widgetA: Widget, currentlyPlacedItem: Widget) {
    // Move items in the list
}

// Implement the required function to get the currently placed item in the list based on drop position
fun getCurrentlyPlacedItemInList(): Widget? {
    // Use pagerState, LocalDragTargetInfo.current.absolutePositionX, 
    // LocalDragTargetInfo.current.absolutePositionY 
    // to determine the currently placed item in the list 
    // based on the drop position
    return null
}

@Composable
fun WidgetsList(pagerState: PagerState, widgetList: List<Widget>){
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(widgetList) { widget ->
            // this composable was defined earlier as
            // each widget item is itself a drag target
            DragTargetWidgetItem(
                data = widget,
                pagerState = pagerState
            )
        }
    }
}

data class Widget(val name: String, val description: String)

You can customize the addWidgetToListmoveWidgets, and getCurrentlyPlacedItemInList functions to handle the actual logic for your app. The Page1ContentWidgetsList, and DragTargetWidgetItem composable functions provide a structured way to set up the draggable widgets and drop targets in your app.

Make sure to replace the Widget class with your actual data model class and customize the handling of the list and drop positions based on your app’s requirements.

With the drop target implementation on each of your screens, you can effortlessly enable drag and drop interactions across all screens, allowing smooth data transfer between different parts of your Android App.

Conclusion

In this guide, we delved into the world of seamless D&D using Jetpack Compose. We built a captivating multi-screen experience, allowing users to effortlessly move data between different screens. By implementing LongPressDraggableDragTargetDropTarget, and DragTargetWidgetItem, we crafted dynamic and intuitive D&D interactions for our use case.

Shoutout —

I would like to extend my gratitude to Radhika for their insightful article on Drag and Drop in Compose. Their work served as a valuable reference and inspiration for this piece. You can check out their article here.

Complete Code and Closing Remarks

Thank you for exploring this guide, and I hope you enjoyed creating your seamless D&D experience using Jetpack Compose.

To make it easier here are two files DragDrop.kt and DragDropUsage.kt that will help you integrate this functionality quickly into your app.

If you liked the content, please feel free to leave your valuable feedback or appreciation. I am always looking to learn and collaborate with fellow developers.

Follow me on Medium for more articles — Medium Profile

Connect with me on LinkedIn for collaboration — LinkedIn Profile

Happy Composing!

 

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu