SnapToFit Effect with JetpackCompose

Table of contents

No heading

No headings in the article.

Imagine that you have a scrollable image list and you want an image should be selected in this list when you scroll. Explained in other words, the user should not be able to scroll an empty space, and when the scrolling is over, a given image should be highlighted in this list.

This is called SnapToFit effect.

snap.gif

With JetpackCompose, this seems quite a bit complicated because the API does not support it for the moment. But if we look more closely to the LazyListLayoutInfo, we may see the visibleItemsInfo field which may be quite helpful to achieve what we need.

package androidx.compose.foundation.lazy

interface LazyListLayoutInfo {
    /**
     * The list of [LazyListItemInfo] representing all the currently visible items.
     */
    val visibleItemsInfo: List<LazyListItemInfo>
}

As we can note, this interface belongs to the lazy package, which means that it comes out of the box if we use a lazyListState.

val listState = rememberLazyListState()
val visibleItems = listState.layoutInfo.visibleItemsInfo

So the question is, what should we do with these visible items? Well, if you think about it, we should use this information in order to observe if these visible items change, and make a decision whether to change the scroll position or not. That's the essence of what we are trying to achieve.

Let's imagine that we want to create an image picker which picks the image positioned in the middle of the screen. The algorithm to achieve this would be:

  • Define a threshold which highlights the picked image
  • Position it in the middle of the screen
  • Position an image which is centered to the highlighted area
  • When the user scrolls, detect if the scrolled area is greater than the half of the highlighted area
    • If true, scroll to the next item
    • Otherwise, scroll back to the current item

We start with defining our measurements, on which our component is based on:

    val selectorSize = 90.dp
    val itemSize = 60.dp
    val spacingSize = 20.dp
    val maxOffset = with(LocalDensity.current) {
        ((selectorSize / 2) + (itemSize / 2) + (spacingSize / 2)).toPx() / 2f
    }

The selectorSize represents the size of the selector which highlights the selected image, the itemSize is the size of each image, the spacingSize is the distance between each image, and the maxOffset represents our permitted scroll range to pick the next item. In other words, this formula will define when we will scroll to the next item if exceeded, or scroll back to the current item.

Up next, we have to measure the current screen width in order to position our item centered to out image highlighter. As we want to center the picked image at the center of our highlighter, we should position the image at the center of the screen with an offset. This offset will be selectorSize - itemSize, in order to give the impression that the image is at the center. This horizontalPadding will assure us of each item's position is exactly in the center of the image picker.

We can obtain these informations like the following:

    val width = with(LocalConfiguration.current) {
        this.screenWidthDp
    }

    val horizontalPadding = width.dp.div(2).minus(selectorSize - itemSize)

We are using the LazyRow thus we should memorize the state and the coroutine scope using:

    val listState = rememberLazyListState(
        initialFirstVisibleItemIndex = model.selected
    )
    val coroutineScope = rememberCoroutineScope()

The model is just a UI model in order to simplify things, and represents a list of images, with the selected image index:

data class ImageModel(
    val images: List<String>,
    val selected: Int
)

Another interesting information that interactionSource exposes us is the current drag state. The following will return a State representing whether this component is dragged or not. We will use this information in order to optimize things, as picking an image only when the user stops dragging:

val isBeingDragged = listState.interactionSource.collectIsDraggedAsState().value

We will observe this state and react to that when it changes. Obviously we don't want to recompose everything, but we just want to scroll to the selected item. To achieve this we may use the LaunchedEffect:

    LaunchedEffect(isBeingDragged) {
        if (!isBeingDragged) {
            listState.layoutInfo.visibleItemsInfo.firstOrNull { element ->
                element.offset >= -maxOffset && element.offset < maxOffset
            }?.let {
                listState.animateScrollToItem(it.index)
                onSelect(it.index)
            }
        }
    }

What we are saying here is "When the user stops dragging, go find me the first visible item which is in the range of our pick threshold".

Our final Composable will look like this:

data class ImageModel(
    val images: List<String>,
    val selected: Int
)

@ExperimentalCoilApi
@Composable
fun SnapToFit(
    modifier: Modifier = Modifier,
    model: ImageModel,
    onSelect: (Int) -> Unit = {}
) {
    val selectorSize = 90.dp
    val itemSize = 60.dp
    val spacingSize = 20.dp
    val maxOffset = with(LocalDensity.current) {
        ((selectorSize / 2) + (itemSize / 2) + (spacingSize / 2)).toPx() / 2f
    }

    val width = with(LocalConfiguration.current) {
        this.screenWidthDp
    }

    val horizontalPadding = width.dp.div(2).minus(selectorSize - itemSize)

    val listState = rememberLazyListState(
        initialFirstVisibleItemIndex = model.selected
    )
    val coroutineScope = rememberCoroutineScope()

    val isBeingDragged = listState.interactionSource.collectIsDraggedAsState().value

    LaunchedEffect(isBeingDragged) {
        if (!isBeingDragged) {
            listState.layoutInfo.visibleItemsInfo.firstOrNull { element ->
                element.offset >= -maxOffset && element.offset < maxOffset
            }?.let {
                listState.animateScrollToItem(it.index)
                onSelect(it.index)
            }
        }
    }

    Box(modifier = modifier.fillMaxWidth()) {
        Box(
            modifier = Modifier
                .size(selectorSize)
                .background(
                    color = Color.LightGray,
                    shape = RoundedCornerShape(16.dp)
                )
                .align(Alignment.Center)
        )

        Images(
            spacingSize = spacingSize,
            horizontalPadding = horizontalPadding,
            listState = listState,
            model = model,
            itemSize = itemSize,
            coroutineScope = coroutineScope,
            onSelect = onSelect
        )
    }
}

@ExperimentalCoilApi
@Composable
private fun BoxScope.Images(
    spacingSize: Dp,
    horizontalPadding: Dp,
    listState: LazyListState,
    model: ImageModel,
    itemSize: Dp,
    coroutineScope: CoroutineScope,
    onSelect: (Int) -> Unit
) {
    LazyRow(
        modifier = Modifier.Companion.align(Alignment.Center),
        horizontalArrangement = Arrangement.spacedBy(spacingSize),
        verticalAlignment = Alignment.CenterVertically,
        contentPadding = PaddingValues(horizontal = horizontalPadding),
        state = listState
    ) {
        itemsIndexed(
            items = model.images,
            key = { index, _ -> index }
        ) { index, icon ->
            IconButton(
                modifier = Modifier.size(itemSize),
                onClick = {
                    coroutineScope.launch {
                        listState.animateScrollToItem(index)
                        onSelect(index)
                    }
                }
            ) {
                Image(
                    modifier = Modifier
                        .clip(RoundedCornerShape(16.dp))
                        .size(itemSize),
                    painter = rememberImagePainter(
                        data = icon,
                        builder = {
                            placeholder(R.drawable.photography)
                        }
                    ),
                    contentDescription = "image"
                )
            }
        }
    }
}

Surely there are better and more efficient ways to implement this, but this works on my computer :D If you have any suggestions for any improvement, please feel free to contact me so that I can update this article in order to leave a more efficient example to the future generations!

Special thanks to @dzamir for reviewing this article.

Cheers!