Android Compose UX Techniques: Implementing Snapping Effects with LazyRow

Ken Ruiz Inoue
Deuk
Published in
6 min readFeb 20, 2024
Designed with DALL-E3

Introduction

In today’s tutorial, we explore the implementation of a snapping effect within a LazyRow component using Android Compose. This functionality enhances the user interface by ensuring items within a list align precisely upon release, significantly improving user interaction and experience. Such an effect is particularly beneficial in applications featuring nested rows or lists that scroll vertically, where seamless navigation and interaction are paramount.

Items snap into place for a polished look

By integrating this snapping effect, developers can augment the aesthetic appeal of their applications and provide a more intuitive and responsive user experience. Let’s delve into the technical details of creating a LazyRow with a snap-to-position feature, emphasizing best practices for Android UI development!

Step 1: LazyRowWithSnap Implementation

First, create an empty project in Android Studio and add the LazyRowWithSnap.kt file with the following content.

// YOUR PACKAGRE

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch

@Composable
fun DefaultItem(itemWidthDp: Dp = 160.dp, itemLabel: String, itemColor: Color = Color.Red) {
// Defines the appearance of each item in the lazy row
Box(
modifier = Modifier
.size(itemWidthDp) // Set the size of the item
.background(itemColor)
) {
// Display the item label centered within the box
Text(
text = itemLabel,
modifier = Modifier.align(Alignment.Center)
)
}
}

@Composable
fun LazyRowWithSnap(
itemWidthDp: Dp = 160.dp, // Default width for each item
paddingDp: Dp = 8.dp, // Default distance between items
items: List<String>, // List of items to display
itemsColor: Color = Color.Red, // Default color for each item
) {
// Key Point 1: Remembering Scroll State and Coroutine Scope
val listState = rememberLazyListState() // Remember the scroll state for lazy row
val coroutineScope = rememberCoroutineScope() // Coroutine scope for launching animations

// Calculate the width of each item in pixels, including padding
val itemWidthPx = with(LocalDensity.current) { (itemWidthDp + paddingDp).toPx() }

// LazyRow displaying items with specified state and modifier
LazyRow(
state = listState,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(paddingDp)
) {
items(items) { item ->
DefaultItem(itemLabel = item, itemWidthDp = itemWidthDp, itemColor = itemsColor)
}
}

// Key Point 2: LaunchedEffect for Scroll Completion and Triggering Snapping
LaunchedEffect(Unit) {
// Observes if scrolling is in progress and calculates the target index for snapping
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (!isScrolling) {
// Calculate the last item index
val lastItemIndex = items.size - 1
// Get the layout info of the LazyList
val layoutInfo = listState.layoutInfo
// Determine if the last item is visible
val isLastItemVisible =
layoutInfo.visibleItemsInfo.any { it.index == lastItemIndex }
if (!isLastItemVisible) {
// Calculate the target index for snapping when scrolling stops
val targetIndex = calculateTargetIndex(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
itemWidthPx,
items.size
)
// Animate scrolling to the target index to achieve snapping effect
coroutineScope.launch {
listState.animateScrollToItem(index = targetIndex)
}
}
}
}
}
}

// Key Point 3: Calculating the Target Index for Snapping
fun calculateTargetIndex(
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
itemWidthPx: Float,
itemCount: Int // Pass the total number of items in the list
): Int {
// Calculate the total scroll offset in pixels
val totalScrollOffset = firstVisibleItemIndex * itemWidthPx + firstVisibleItemScrollOffset
// Calculate the index based on the scroll offset
var targetIndex = (totalScrollOffset / itemWidthPx).toInt()

// Determine the fraction of the item that is visible
val visibleItemFraction = totalScrollOffset % itemWidthPx
// If more than half of the item is shown, snap to the next item
if (visibleItemFraction > itemWidthPx / 2) {
targetIndex++
}

// Special case: when the user has scrolled to the end, snap to the last item
if (targetIndex >= itemCount - 1) {
targetIndex = itemCount - 1
}

return targetIndex
}

@Preview
@Composable
fun LazyRowWithSnapPreview() {
// Preview for LazyRowWithSnap composable function
LazyRowWithSnap(items = (0..10).map { "Item $it" })
}

Key Point 1: Remembering Scroll State and Coroutine Scope

  • rememberLazyListState(): This state object keeps track of the scroll position and other details about the scrolling behavior of the LazyRow. It's essential for programmatically implementing the snapping effect.
  • rememberCoroutineScope(): This is used to launch coroutines from the Composable function. In this context, it's specifically used to perform smooth scroll animations to the target position when implementing the snapping effect.

Key Point 2: LaunchedEffect for Scroll Completion and Triggering Snapping

  • LaunchedEffect with snapshotFlow: LaunchedEffect is a side-effect that runs when the composable enters the composition and is canceled when it leaves the composition or the key changes. Inside this LaunchedEffect, snapshotFlow is used to observe changes to whether the LazyRow is currently being scrolled. When the user stops scrolling (isScrollInProgress becomes false), the code calculates which item the list should snap to.
  • Calculating Snap Position and Scrolling: The code calculates the target index for snapping based on the current scroll position after determining that scrolling has stopped. It then uses listState.animateScrollToItem within a coroutine to smoothly scroll to the target item, creating the snapping effect.

Key Point 3: Calculating the Target Index for Snapping

  • This function calculates which item index the LazyRow should snap to based on the current scroll offset and the width of the items. It considers the first visible item index, its scroll offset, the width of the items (including any padding), and the total number of items. The function also handles edge cases, like disabling the snapping effect if the user scrolls towards the end of the list.

Activate the preview to ensure the composable function works as expected, showcasing the snapping effect. Bear in mind as you approach the list’s end, the snapping effect will dissipate.

Step 2: Updating MainActivity

Finally, update the MainActivity.kt as follows to see the result in a running app.

// YOUR PACKAGE

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Sets the content of the activity to be the composable function LazyRowWithSnap
setContent {
Box(
modifier = Modifier.padding(8.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
LazyRowWithSnap(items = (0..10).map { "Item $it" })
LazyRowWithSnap(items = (0..20).map { "Item $it" }, itemsColor = Color.Blue)
LazyRowWithSnap(items = (0..30).map { "Item $it" }, itemsColor = Color.Green)
}
}
}
}
}

Conclusion

Congratulations on completing this tutorial on implementing the snapping effect within a LazyRow component using Android Compose. Before incorporating this implementation into your projects, it’s important to note a couple of considerations:

  1. Customization Requirement: The LazyRowWithSnap component, as demonstrated, is designed to work with DefaultItem. You may need to adjust the implementation accordingly to integrate different types of composables or support a broader range of item designs.
  2. Optimization Opportunities: While the calculateTargetIndex() function effectively determines the snapping position, further optimizations could be explored to handle additional edge cases and refine the overall user experience. Although the current version offers a solid foundation, refining this logic will ensure a more robust and production-ready feature.

As you continue to explore the possibilities of Android Compose, remember that the journey to mastering UI/UX design is ongoing. The techniques discussed today are just a starting point for creating more engaging and dynamic user interfaces.

For more insights and guides on modern Android development, consider exploring my additional resources.

Thank you for joining me in this tutorial. If you found it helpful, please support with claps or follow for more insightful content. Stay tuned for more updates and happy coding!

Deuk Services: Your Gateway to Leading Android Innovation

Are you looking to boost your business with top-tier Android solutions?Partner with Deuk services and take your projects to unparalleled heights.

--

--