avatarAbdellah Ibn El Azraq

Summarize

Elevate Your Android App’s Visual Appeal with Simple and Elegant Animations in Jetpack Compose.

In the fast-paced world of mobile app development, creating an engaging and visually captivating user experience is paramount. A well-designed app should not only be functional but also aesthetically pleasing. After all, first impressions matter, and a visually appealing interface can make the difference between a user’s fleeting interest and their unwavering loyalty.

In this article, we’ll delve into the exciting realm of Android app development using Jetpack Compose, an innovative framework that simplifies the creation of user interfaces. We’ll explore how to incorporate a simple yet elegant animation into a LazyRow, a versatile layout system for displaying horizontally scrollable lists. By the end, you’ll have the tools to enhance your app’s aesthetics and interactivity, ensuring that it remains at the forefront of user engagement.

Let’s embark on this journey to infuse your Android app with vitality and charm, making it a place users can’t resist returning to.

This is the animation that we will learn in this article:

The idea is to make the first visible item of the LazyRow Larger than the rest, it is simple and elegant so let’s jump to coding.

Code:

First of all, we create a data class that represent the item of LazyRow

data class Item(
    val name : String,
    val imageId : Int
)

Next, we create LazyRow item composable:

private const val MAX_HEIGHT = 117
private const val MAX_WIDTH = 123
private const val MIN_HEIGHT = 101
private const val MIN_WIDTH = 84

@Composable
fun LazyRowItem(
    item : Item
) {
    Card(
        modifier = Modifier
            .height(MIN_HEIGHT.dp)
            .width(MIN_WIDTH.dp),
        shape = RoundedCornerShape(10.dp)
    ) {
        Image(
            painter = painterResource(id = item.imageId),
            contentDescription = item.name,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )
    }
}

You can modify the sizes or the composable as you desire, the MIN_HEIGHT and MIN_WIDTH are the sizes of the item when it is in a normal state, the MAX_HEIGHT and MAX_WIDTH are for the larger item, which is the first visible item in the LazyRow.

Now we create the LazyRow:

LazyRow(
  modifier = Modifier.fillMaxWidth(),
  horizontalArrangement = Arrangement.spacedBy(10.dp)
  ){
      items(list){item->
         LazyRowItem(item = item)
     }
  }

Currently the application looks like this:

To add the animation to the LazyRow first we will have a firstVisibleItemIndex state assigned to the first visible item in the LazyRow and we listen to the changes of the firstVisibleItemScrollOffset in the lazyRow, each time the offset changes we update the firstVisibleItemIndex

val lazyRowState = rememberLazyListState()

var firstVisibleItemIndex by remember {
    mutableStateOf(0)
}

LaunchedEffect(lazyRowState){
    snapshotFlow {
        lazyRowState.firstVisibleItemScrollOffset
    }.collectLatest {offset ->
        val itemSize = lazyRowState.layoutInfo.visibleItemsInfo.firstOrNull()?.size ?: 0
        firstVisibleItemIndex = lazyRowState.firstVisibleItemIndex
        if(offset >= itemSize/2)
            firstVisibleItemIndex++
    }
}

LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
state = lazyRowState
){
    items(list){item->
        LazyRowItem(item = item)
    }
}

Let’s break down the code in simple terms:

  1. val lazyRowState = rememberLazyListState(): This line creates a variable called lazyRowState that remembers the state of a LazyRow, which is a scrollable horizontal list. It keeps track of how the list is currently scrolled.
  2. var firstVisibleItemIndex by remember { mutableStateOf(0) }: This line initializes a variable called firstVisibleItemIndex. It remembers the index of the first item that is currently visible in the LazyRow. We start with the assumption that the first item is visible, so it's set to 0.
  3. LaunchedEffect(lazyRowState) { ... }: This code sets up an effect that runs when lazyRowState changes. It listens for changes in the scroll position of the LazyRow.
  4. snapshotFlow { ... }: This is a flow that captures the current state of the LazyRow, specifically the amount it has been scrolled horizontally.
  5. .collectLatest { offset -> ... }: This part of the code collects the latest value from the flow (the scroll offset) and then does the following:
  • It calculates the size of the currently visible items in the LazyRow.
  • It updates the firstVisibleItemIndex to match the index of the first item that is currently visible.
  • If the scroll offset is greater than or equal to half the size of an item, it increments the firstVisibleItemIndex. This means that if you've scrolled halfway through an item, the next item will be considered the first visible one.

and don’t forget to assign lazyRowState to the state parameter of the LazyRow.

Next we update the LazyRowItem composable code and add some changes to the LazyRow code

@Composable
fun LazyRowItem(
    item : Item,
    isLarge : Boolean
) {
    Card(
        modifier = Modifier
            .height(if(isLarge) MAX_HEIGHT.dp else MIN_HEIGHT.dp)
            .width(if(isLarge) MAX_WIDTH.dp else MIN_WIDTH.dp),
        shape = RoundedCornerShape(10.dp)
    ) {
        Image(
            painter = painterResource(id = item.imageId),
            contentDescription = item.name,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )
    }
}
LazyRow(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.spacedBy(10.dp),
    state = lazyRowState
){
    itemsIndexed(list){index,item->
        LazyRowItem(
            item = item,
            isLarge = index == firstVisibleItemIndex
        )
    }
}

If we run the app now, the animation should work

To make the transition smooth we call animateContentSize() in our Card composable and before height:

@Composable
fun LazyRowItem(
    item : Item,
    isLarge : Boolean
) {
    Card(
        modifier = Modifier
            .animateContentSize()
            .height(if(isLarge) MAX_HEIGHT.dp else MIN_HEIGHT.dp)
            .width(if(isLarge) MAX_WIDTH.dp else MIN_WIDTH.dp),
        shape = RoundedCornerShape(10.dp)
    ) {
        Image(
            painter = painterResource(id = item.imageId),
            contentDescription = item.name,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )
    }
}

One last thing, if you have another composable below the LazyRow it will bounce up and down each time the LazyRow is scrolling, like is shown here:

And that is because we change the size of the card and the spacing between the lazyRow and the Text changes, to fix that we just need to put the card inside a Boxand give the box MAX_HEIGHT.

This is the full code:

private const val MAX_HEIGHT = 117
private const val MAX_WIDTH = 123
private const val MIN_HEIGHT = 101
private const val MIN_WIDTH = 84

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val list = listOf(
            Item("image_1",R.drawable.image_1),
            Item("image_2",R.drawable.image_2),
            Item("image_3",R.drawable.image_3),
            Item("image_4",R.drawable.image_4),
            Item("image_5",R.drawable.image_5),
            Item("image_6",R.drawable.image_6),
            Item("image_7",R.drawable.image_7),
            Item("image_8",R.drawable.image_8),
            Item("image_9",R.drawable.image_9),
        )

        setContent {
            JetpackComposeLazyRowAnimationTheme {
                Surface(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(20.dp),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Column {
                        val lazyRowState = rememberLazyListState()

                        var firstVisibleItemIndex by remember {
                            mutableStateOf(0)
                        }

                        LaunchedEffect(lazyRowState){
                            snapshotFlow {
                                lazyRowState.firstVisibleItemScrollOffset
                            }.collectLatest {offset ->
                                val itemSize = lazyRowState.layoutInfo.visibleItemsInfo.firstOrNull()?.size ?: 0
                                firstVisibleItemIndex = lazyRowState.firstVisibleItemIndex
                                if(offset >= itemSize/2)
                                    firstVisibleItemIndex++
                            }
                        }

                        LazyRow(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(10.dp),
                            state = lazyRowState
                        ){
                            itemsIndexed(list){index,item->
                                LazyRowItem(
                                    item = item,
                                    isLarge = index == firstVisibleItemIndex
                                )
                            }
                        }

                        Spacer(modifier = Modifier.height(40.dp))
                        Text(text = "LazyRow Animation")
                    }
                }
            }
        }
    }
}

@Composable
fun LazyRowItem(
    item : Item,
    isLarge : Boolean
) {
    Box(modifier = Modifier.height(MAX_HEIGHT.dp)){
        Card(
            modifier = Modifier
                .animateContentSize()
                .height(if (isLarge) MAX_HEIGHT.dp else MIN_HEIGHT.dp)
                .width(if (isLarge) MAX_WIDTH.dp else MIN_WIDTH.dp),
            shape = RoundedCornerShape(10.dp)
        ) {
            Image(
                painter = painterResource(id = item.imageId),
                contentDescription = item.name,
                contentScale = ContentScale.Crop,
                modifier = Modifier.fillMaxSize()
            )
        }
    }
}

And that is all! if you want to learn more about android development follow me and check my other articles, if you want to see this animation in action check the repository of my Cloud Castle Android app.

If you enjoyed this article, consider trying out the AI service I recommend. It provides the same performance and functions to ChatGPT Plus(GPT-4) but more cost-effective, at just $6/month (Special offer for $1/month). Click here to try ZAI.chat.

Android
Jetpack Compose
Jetpack Compose Animation
Lazy Row
Animation
Recommended from ReadMedium