Create Instagram-like Long Press and Draggable Carousel Indicators in Jetpack Compose

Pushpal Roy on 2024-03-03

Create Instagram-like Long Press and Draggable Carousel Indicators in Jetpack Compose

We must have used this UX in the Instagram mobile app, where we can long press the carousel indicators section and then swipe left or right for fast scrolling. This is an extremely useful user experience when it comes to quick viewing of the images.

If we look closely, the indicator sizes also diminish for images further away from the current selection, providing a focused visual effect.

In this article, we will learn how to create this UX using Jetpack Compose. The idea is to create a composable named DraggableIndicator and use it along with a HorizontalPager.

So let’s get started.

Step-by-step Approach

Step 1: Set up the basic composable structure

We have to create a composable called DraggableIndicator which will look like this. It will have the following arguments:

@Composable
fun DraggableIndicator(
    modifier: Modifier = Modifier,
    state: PagerState,
    itemCount: Int,
    onPageSelect: (Int) -> Unit,
) {
    Box(modifier = modifier) {
        // Indicators will be added here
    }
}

Step 2: Add the indicators

Next, we will draw each indicator using a Box and drawBehind modifier with an initial style ofGray color and add them in a horizontal row using a LazyRow.

LazyRow(
    modifier = Modifier.padding(8.dp).widthIn(max = 100.dp),
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    verticalAlignment = Alignment.CenterVertically
) {
    items(itemCount) { index ->
        Box(
            modifier = Modifier
                .size(10.dp)
                .drawBehind {
                  drawCircle(color)
                }
        )
    }
}

Step 3: Adjust indicator size and color based on the current page

Now as we discussed at the start, we want to create a visual effect for our indicators, such that the indicator sizes diminish for images further away from the current selection. To achieve this, we have to calculate a scaleFactor for each indicator dot, based on the currentPage state and the indicator index. The currentPage here is simply the Int state obtained from the PagerState of the pager. Also, we are switching the color of each indicator between 2 values based on the currently selected page.

items(itemCount) { i ->
    val scaleFactor = 1f - (0.1f * abs(i - currentPage)).coerceAtMost(0.4f)
    val color = if (i == currentPage) Color(0xFF03A9F4) else Color.Gray
    Box(
        modifier = Modifier
            .size(10.dp)
            .graphicsLayer {
                scaleX = scaleFactor
                scaleY = scaleFactor
            }
            .drawBehind {
                drawCircle(color)
            }
    )
}

The formula we are using to calculate the scale factor for each indicator here is:

val scaleFactor = 1f - (0.1f * abs(i - currentPage)).coerceAtMost(0.4f)

Let’s try to understand what is happening in the above formula:

Step 4: Enable long-press and drag gestures

Now, the most interesting part. We need to implement a gesture that will enable us to first long-press on the indicators and then scroll left and right. For this will use the pointerInput Modifier along with the detectDragGesturesAfterLongPress suspend function which is an extension of the PointerInputScope. This is a very useful function of the androidx.compose.foundation.gestures package.

val accumulatedDragAmount = remember { mutableFloatStateOf(0f) }
var enableDrag by remember { mutableStateOf(false) }

Modifier.pointerInput(Unit) {
    detectDragGesturesAfterLongPress(
        onDragStart = {
            enableDrag = true
            accumulatedDragAmount.floatValue = 0f
        },
        onDrag = { change, dragAmount ->
            change.consume()
            accumulatedDragAmount.floatValue += dragAmount.x
            // Logic to update currentPage based on drag
        },
        onDragEnd = {
            enableDrag = false
            accumulatedDragAmount.floatValue = 0f
        }
    )
}

Let’s try to understand what is happening:

Here the gesture detector waits for the pointer down and long press, after which it calls onDrag for each drag event, which is exactly what we need. onDragStart is called when a long press is detected and onDragEnd is called after all pointers are up. The enableDrag flag tracks the point when drag is enabled and disabled. This is important because we want the drag to be disabled as soon as the user leaves the long press. The accumulatedDragAmount tracks the total drag distance, which will be used later to decide page scrolling.

Step 5: Change pages on drag

Now inside the onDrag lambda, we will use the value of accumulatedDragAmount to incorporate the logic to change the current page.

First, we need to calculate a drag threshold. The threshold represents the minimum distance the user needs to drag to trigger a page change. This mechanism ensures that slight, unintentional drags do not cause the carousel to switch pages. We will not hardcode this value to a fixed dp because the threshold should change based on the number of items in the carousel. Otherwise, the user might have to swipe more or less if the item count changes causing a weird experience. This is a one-time calculation that we need to add at the top of our composable. Feel free to play around with this calculation to create a desired user experience.

val density = LocalDensity.current
val threshold = remember {
    with(density) {
        ((80.dp / (itemCount.coerceAtLeast(1))) + 10.dp).toPx()
    }
}

Now using the threshold and accumulatedDragAmount we will write the logic to change the current page inside onDrag.

if (abs(accumulatedDragAmount.value) >= threshold) {
    val nextPage = if (accumulatedDragAmount.value < 0) state.currentPage + 1 else state.currentPage - 1
    val correctedNextPage = nextPage.coerceIn(0, itemCount - 1)

    if (correctedNextPage != state.currentPage) {
        onPageSelect(correctedNextPage)
    }
    accumulatedDragAmount.value = 0f
}

Let’s try to understand what is happening:

Our implementation of the drag gesture is completed. Now we need to make sure that the LazyRow of indicators scroll automatically when the user drags. For this, we will add this LaunchedEffect at the top of our composable which animate scroll to the currentPage index whenever the page changes.

LaunchedEffect(currentPage) {
    coroutineScope.launch {
        lazyListState.animateScrollToItem(index = currentPage)
    }
}

Step 7: Add interactive haptic feedback!

Now when everything is done, it will be cool to add some kind of haptic feedback effect (sensory effect that is created by a device vibrating) so that users get feedback with the interaction. We will provide feedback at 2 points: One when drag is enabled after long-press and then when each page changes.

To do this first we need to initialize a hapticFeedback at the top:

 val haptics = LocalHapticFeedback.current

Then use it when inside onDragStart when drag is enabled after long-press on the indicators:

.pointerInput(Unit) {
  detectDragGesturesAfterLongPress(
      onDragStart = {
          haptics.performHapticFeedback(HapticFeedbackType.LongPress)
          accumulatedDragAmount.floatValue = 0f
          enableDrag = true
      },
// Remaining code

Also inside onDrag when the page changes:

onDrag = { change, dragAmount ->
  if (enableDrag) {
      change.consume()
      accumulatedDragAmount.floatValue += dragAmount.x
      if (abs(accumulatedDragAmount.floatValue) >= threshold) {
          // Remaining code
          if (correctedNextPage != state.currentPage) {
              haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
              onPageSelect(correctedNextPage)
          }
          // Remaining code
      }
  }
},
// Remaining code

That’s it.

We have come a long way to create our composable DraggableIndicator.

Now we will use this in our code.

Use the composable in our code

Consider this sample code for usage. Here we are using a HorizontalPager to show a list of image items in a carousel. Below the Pager, we are showing the indicators using DraggableIndicator.

val state = rememberPagerState(
    initialPage = 0,
    initialPageOffsetFraction = 0f
) { list.size } // Here, list is images list

val coroutineScope = rememberCoroutineScope()
Column(
    modifier = Modifier.padding(24.dp),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    HorizontalPager(
        modifier = Modifier.height(280.dp),
        state = state,
    ) { page ->
        // Pager content: Image, Card, etc.
    }
    Spacer(modifier = Modifier.height(16.dp))
    // Using our composable here
    DraggableIndicator(
        modifier = Modifier,
        state = state,
        itemCount = colorList.size,
        onPageSelect = { page ->
            coroutineScope.launch {
                state.scrollToPage(page)
            }
        },
    )
}

Final Result

Source code

The entire source code is available here: https://github.com/pushpalroy/JetDraggableIndicators