# 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.

![](https://miro.medium.com/v2/resize:fit:640/1*eeIicZiiLx5Jv1_lSwIG9Q.gif align="center")

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`.

```kotlin
@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 of`Gray` color and add them in a horizontal row using a `LazyRow`.

```kotlin
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.

```kotlin
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:

```kotlin
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.

```kotlin
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.*

```kotlin
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`.

```kotlin
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.

```kotlin
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:

```kotlin
 val haptics = LocalHapticFeedback.current
```

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

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

Also inside onDrag when the page changes:

```kotlin
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`.

```kotlin
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**

![](https://miro.medium.com/v2/resize:fit:1200/1*TJxMUUsb7QWUxHXVNvdznA.gif align="left")

### **Source code**

The entire source code is available here:

%[https://github.com/pushpalroy/JetDraggableIndicators]
