Create Instagram-like Long Press and Draggable Carousel Indicators in Jetpack Compose
Table of contents
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
: APagerState
instance which will be used by theHorizontalPager
.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 by0.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 determinesnextPage
.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, hencestate.currentPage - 1
.correctedNextPage
: ThenextPage
value is then coerced within the bounds of0
anditemCount - 1
usingcoerceIn
. 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 fromstate.currentPage
, theonPageSelect(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 to0f
. 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: