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

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

Featured on Hashnode

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:

  • pagerState: A PagerState instance which will be used by the HorizontalPager.

  • itemCount: The total count of items or images in the pager.

  • onPageSelected: A callback function that is invoked when a new page is selected through drag gestures.

We’ll start the parent with a simple Box.

@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:

  • 1f: This represents the base scale factor, implying that in the default state (when the indicator is the current page), it should not be scaled down at all (i.e., it retains its original size).

  • 0.1f * abs(i - currentPage): This portion of the formula calculates the difference in position between the current indicator (i) and the currently selected page (currentPage). The absolute value (abs) ensures that the distance is always a positive number, regardless of whether the current indicator is before or after the current page. This distance is then multiplied by 0.1f, determining how much the scale factor decreases as the distance from the current page increases. This means, that for each step away from the current page, the indicator scales down by 10%.

  • .coerceAtMost(0.4f): This method caps the maximum scale-down effect to 40% (0.4f). Without this cap, indicators far away from the current page could become too small or even disappear. By limiting the scale factor reduction to a maximum of 40%, we ensure that all indicators remain visible and maintain a minimum scale, contributing to a balanced and harmonious visual effect across the carousel.

The result is a dynamic scaling effect where the size of each indicator smoothly decreases as the distance from the current page increases, up to a maximum scale reduction.

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:

  • abs(accumulatedDragAmount.value) >= threshold: This condition checks if the absolute value of the accumulated drag amount (i.e., the total distance the user has dragged, disregarding direction) has reached or exceeded a predefined threshold. This determines nextPage.

  • If accumulatedDragAmount.value < 0, it implies the user has dragged to the left, intending to move to the next page. Therefore, state.currentPage + 1 is computed.

  • Conversely, if accumulatedDragAmount.value > 0, the user has dragged to the right, indicating a move to the previous page, hence state.currentPage - 1.

  • correctedNextPage: The nextPage value is then coerced within the bounds of 0 and itemCount - 1 using coerceIn. This step ensures that the page index stays within the valid range of pages available in the carousel, preventing index out-of-bounds errors.

  • If correctedNextPage is different from state.currentPage, the onPageSelect(correctedNextPage) callback is invoked. This action effectively updates the carousel to display the new page selected by the user's drag gesture.

  • Finally, accumulatedDragAmount.value is reset to 0f. This reset is crucial for starting the drag distance calculation afresh for subsequent drag gestures, ensuring that each drag gesture is evaluated independently of the previous ones.

Step 6: Scroll to the indicator position on the drag

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:

Did you find this article valuable?

Support Pushpal Roy by becoming a sponsor. Any amount is appreciated!