DEV Community

Cover image for Creating interactive UIs with Motion Layout using Jetpack Compose
AKASH PATEL for MindInventory

Posted on

Creating interactive UIs with Motion Layout using Jetpack Compose

After going through this blog you’ll achieve this kind of polished animation using MotionLayout:

Let’s take baby steps and kick start with the Intro.

Introduction

As an Android developer, you might have encountered the need for layout animation, and sometimes even morphing-style layout animation. That’s where MotionLayout comes into the picture.

It fills the chasm between layout transitions and complex motion handling, offering a diversity of features that lie between the capabilities of the property animation framework.

While MotionLayout has been around for some time in XML view, it’s fairly new in Jetpack Compose and is still maturing. In this comprehensive guide, we’ll explore MotionLayout in Jetpack Compose with an example of Collapsing toolbar.

Prior to the MotionLayout, Collapsing Toolbar has always been an intriguing subject in Android. I believe you are well acquainted with how the implementation of the Collapsing toolbar with somewhat knotty animations was prolonged and a bit monotonous with the old XML-based view system.

We’ll focus on how we can achieve such complex Collapsing effects using MotionLayout in Jetpack compose.

Some common motion terminology

  1. MotionLayout — A MotionLayout API for the Old view system.

  2. MotionCompose — A MotionLayout API for Jetpack Compose.

  3. MotionScene — A file that defines the various Constraint Sets, Transitions, and Keyframes for a MotionLayout animation.

  4. ConstraintSet — A set of constraints that define the initial and final layout states, along with any intermediate states, for a MotionLayout.

  5. Transition - The animation sequence that occurs between two or more Constraint Sets in a MotionLayout.

  6. KeyAttribute - A property of a view that can be animated during a MotionLayout transition, such as its position, size, or alpha value.

In this blog, we’ll learn about how to incorporate MotionCompose in the world of Jetpack Compose.

In the mists of time before Compose

Firstly, a quick detour. In the XML-based view system, We were creating collapsing app bars/toolbars using the AppBarLayout and CollapsingToolbarLayout while keeping CoordinatorLayout as a parent layout.

The MotionLayout XML file contains information about the transitions and animations for the child views.

How it goes with Compose

We can achieve the same in Jetpack Compose and nearly everything is entirely customizable and easy to implement!

Here it’s implemented using a dedicated Composable function called MotionLayout. The MotionLayout Composable is added as a child element to a parent layout Composable, and the child views are added as direct children of the MotionLayout Composable.

The transitions and animations are defined using a MotionScene object, which is created programmatically in Kotlin.

Tenor giphy

Why do you need MotionLayout?

Visual illustrations are very important when it comes to condensing information down so that the user does not feel overwhelmed while they are surfing through your app.

The animation works seamlessly regardless of the presence of a notch, hardware navigation, etc, or the lack thereof. Now, you do not need MotionLayout to achieve this but, it offers a neat solution by allowing you to constrain the position of the view to align with the layout.

Sometimes we may need to animate numerous properties/attributes of a composable depending on the keyframes of an animation, or we might want to have a complex animation. This is where MotionLayout really shines, streamlining the whole process by defining ConstraintSets that tell how the layout/UI would look at the starting point of the animation and how it would be like at the end, and simply MotionLayout will animate through those sets.

Kicking off

This documentation is based on the compose constraint layout version 1.0.1.

Include the following dependency in the module-level build.gradle’s dependencies section.

implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"
Enter fullscreen mode Exit fullscreen mode

Logically, we’d need to use constraint layout dependency since MotionLayout is a subclass of a Constraint layout.

Let’s have a look at the Compose counterpart and explore its distinctiveness from the traditional MotionLayout approach.

Tenor giphy

MotionLayout vs. MotionCompose

The first disparity between MotionLayout and MotionCompose is that MotionLayout allows developers to define animations in XML, On the other hand, MotionCompose is a new animation library introduced with Jetpack Compose. It provides a declarative way to create and control animations in Compose UI.

MotionCompose is designed to provide a similar level of control and flexibility as MotionLayout but in a more declarative and composable way.

Perks of MotionCompose over MotionLayout:

  • More flexibility

  • Easier to use

  • The simplified syntax for creating animations

  • Easy to modify animations at runtime

  • Empowers the creation of animations that are highly responsive and interactive, facilitating the seamless creation of captivating user experiences.

In summary, both MotionLayout and MotionCompose are powerful tools for handling motion and animation in Android. MotionLayout is better suited for complex animations with a large number of views and constraints, while MotionCompose is more suited for creating smooth and fluid animations in a declarative and composable way. But for the time being, we’ll refer to it as MotionLayout to avoid any confusion.

Overloads

There are different types of MotionLayout functions available with different signatures. Some function accepts MotionScene and another counterpart, you can directly add a MotionScene string as content.

The MotionLayout has a powerful array of properties in its quiver, the table below is a vital resource that would fix your confusion of selecting the right method.

Bear in mind that as the screen content grows it’d become baffling, so to make it easy and clean, JSON5 would be preferable. You can peruse the overload options presented below as per your use case.

Motion Signature — 1

@ExperimentalMotionApi
@Composable
fun MotionLayout(
    start: ConstraintSet,
    end: ConstraintSet,
    transition: androidx.constraintlayout.compose.Transition? = null,
    progress: Float,
    debug: EnumSet<MotionLayoutDebugFlags> = EnumSet.of(MotionLayoutDebugFlags.NONE),
    modifier: Modifier = Modifier,
    optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
    crossinline content: @Composable MotionLayoutScope.() -> Unit
)
Enter fullscreen mode Exit fullscreen mode

Motion Signature — 2

@ExperimentalMotionApi
@Composable
fun MotionLayout(
    motionScene: MotionScene,
    progress: Float,
    debug: EnumSet<MotionLayoutDebugFlags> = EnumSet.of(MotionLayoutDebugFlags.NONE),
    modifier: Modifier = Modifier,
    optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
    crossinline content: @Composable (MotionLayoutScope.() -> Unit),
)
Enter fullscreen mode Exit fullscreen mode

Motion Signature — 3

@ExperimentalMotionApi
@Composable
fun MotionLayout(
    motionScene: MotionScene,
    constraintSetName: String? = null,
    animationSpec: AnimationSpec<Float> = tween<Float>(),
    debug: EnumSet<MotionLayoutDebugFlags> = EnumSet.of(MotionLayoutDebugFlags.NONE),
    modifier: Modifier = Modifier,
    optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
    noinline finishedAnimationListener: (() -> Unit)? = null,
    crossinline content: @Composable (MotionLayoutScope.() -> Unit)
)
Enter fullscreen mode Exit fullscreen mode

Motion Signature — 4

@ExperimentalMotionApi
@Composable
fun MotionLayout(
    start: ConstraintSet,
    end: ConstraintSet,
    transition: androidx.constraintlayout.compose.Transition? = null,
    progress: Float,
    debug: EnumSet<MotionLayoutDebugFlags> = EnumSet.of(MotionLayoutDebugFlags.NONE),
    informationReceiver: LayoutInformationReceiver? = null,
    modifier: Modifier = Modifier,
    optimizationLevel: Int = Optimizer.OPTIMIZATION_STANDARD,
    crossinline content: @Composable MotionLayoutScope.() -> Unit
)
Enter fullscreen mode Exit fullscreen mode

The first overload is the most primitive one.

In the MotionLayout, there are two states to animate. One is starting state and another is the ending state.

Progress is used to determine the current state of the animation between the starting and ending states:

  • 0 means current progress is at “start”.

  • 1 means progress has reached to “end”.

  • 0.5 means the current is in the middle of two, etc.

MotionLayout for Compose takes on

The constraint set can be defined in 2 ways:

  1. MotionScenes Inside MotionLayout.

  2. JSON5 approach.

Both approaches have their pros and cons.

Depiction of a MotionScene’s procedure within the MotionLayout

We can add a MotionScene string as content like this.

MotionLayout(
            start = ConstraintSet {
                ...
            },
            end = ConstraintSet {
                ...
            },
            progress = progress,
            modifier = Modifier
        ) {
          ...
        }
Enter fullscreen mode Exit fullscreen mode

The downside of employing this approach is that as the content grows, it may become baffling.

Let's have a look at the example:

@Composable
fun MyMotionLayout() {
    val motionScene = remember { MotionScene() }

    MotionLayout(
        modifier = Modifier.fillMaxSize(),
        motionScene = motionScene
    ) {
        Box(
            modifier = Modifier
                .constrainAs(box) {
                    start.linkTo(parent.start)
                    top.linkTo(parent.top)
                    end.linkTo(parent.end)
                    bottom.linkTo(parent.bottom)
                }
        ) {
            // Add your UI elements here
        }
    }

    // Define the start and end constraint sets
    motionScene.constraints(
        createConstraints(
            R.id.box,
            start = ConstraintSet {
                // Define your start constraints here
            },
            end = ConstraintSet {
                // Define your end constraints here
            }
        )
    )

    // Define the motion animations
    motionScene.transition(
        createTransition(
            R.id.box,
            fromState = R.id.start,
            toState = R.id.end
        ) {
            // Define your motion animations here
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

JSON5 approach

This blog focuses primarily on this approach and you’ll see an example of this approach in a few moments.

First off, Create a JSON5 file for the MotionScene at res/raw/motion_scene.json5

The structure of the file would be somewhat like this:

{
  ConstraintSets: {
    start: {
      ....
    },
    end: {
      ....
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Here the start contains all the constraints for the initial state of motion, and the end includes constraints for the final state.

Now we have to incorporate JSON5 file content into the compose file.

Using openRawResource you can instantiate your JSON5 file which is residing in the raw folder.

Linking MotionScene objects with appropriate composable can be done this way.

val context = LocalContext.current
val motionScene = remember {
    context.resources
        .openRawResource(R.raw.motion_scene)
        .readBytes()
        .decodeToString()
}

MotionLayout(
    motionScene = MotionScene(content = motionScene),
) { ... }
Enter fullscreen mode Exit fullscreen mode

Tenor giphy

Time to comprehend MotionScene

MotionScene file consists of the following components:

1. ConstraintSets:

  • ConstraintSets are the building blocks of a MotionScene. They define the layout and styling properties of the UI elements.
  • A ConstraintSet contains a set of constraints, which specify the position, size, margin, padding, and other layout properties of each UI element.

2. Transitions:

  • Transitions define the animation or transition between two ConstraintSets. They specify the duration, easing, and other animation properties.
  • A Transition can contain multiple KeyFrame, which defines the intermediate states of the animation or transition.
  • We’ll talk in-depth about attributes that are used inside Transitions in the forthcoming section.

3. KeyFrames:

  • KeyFrames define the intermediate states of a Transition. They specify the properties of the UI elements at specific points in the animation or transition.
  • A KeyFrame can contain a set of PropertySets, which specify the properties of the UI elements.

4. PropertySets:

  • PropertySets specify the properties of the UI elements in a KeyFrame.
  • They can contain properties such as position, size, margin, padding, background color, text color, and so on.

Let's have a look at Transitions

Think of Transitions as a container containing an arbitrary amount of transitions as per necessity.

Each Transition is given a name. The name “default” is special and defines the initial transition.

An illustration of a transition is given below. Take a look at how and what attributes are being used within the Transitions block.

Transitions: {
    default: {
        from: 'start',
        to: 'end',
        pathMotionArc: 'startHorizontal',
        duration: 900
        staggered: 0.4,
        onSwipe: {
                anchor: 'box1',
                maxVelocity: 4.2,
                maxAccel: 3,
                direction: 'end',
                side: 'start',
                mode: 'velocity'
         }
        KeyFrames: {
        KeyPositions: [
            {
            target: ['a'],
            frames: [25, 50, 75],
            percentX: [0.4, 0.8, 0.1],
            percentY: [0.4, 0.8, 0.3]
            }
        ],
        KeyCycles: [
            {
                target: ['a'],
                frames: [0, 50, 100],
                period: [0 , 2 , 0],
                rotationX: [0, 45, 0],
                rotationY: [0, 45, 0], 
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

The above is the transition from ConstraintSet “start” to “end” paths.

Time for the investigation of Transitions terms 🔎

  1. from — id of the ConstraintSet indicating the starting point.

  2. to — id of the ConstraintSet to end at.

  3. duration — time taken by the transition to happen.

  4. pathMotionArc — move in quarter ellipse arcs.

  5. staggered — objects move in a staggered fashion. Either based on start position or stagger value.

  6. onSwipe — enable drag gestures to control the transition.

  7. KeyFrames — modify points between the transitions.

Some commonly used Transition key attributes

  1. Alpha:
  • You can apply alpha property frame-wise within “KeyAttributes” inside the JSON5 script.

    alpha: [0.3, 0.5, 0.9, 0.5, 0.3]

2. Visibility:

  • You can apply this property to the children views which we define as an object within the start and end ConstraintSets.

3. Scale:

  • To change the scale of the image as it moves? That’s where scaleX and scaleY properties come into the picture.

  • ScaleX — To scale an object like an image, per se horizontally.

  • ScaleY — To scale an object vertically.

  • You can apply scaling properties in the following manner as shown below inside KeyAttributes:

    scaleX: [1, 2, 2.5, 2, 1],
    scaleY: [1, 2, 2.5, 2, 1]

4. Elevation

  • It provides elevation which is self-explanatory, right!

5. Rotation:

  • rotationX — To rotate/flip/twirl an object on X-axis.

  • rotationY — To rotate/flip/twirl an object on Y-axis.

6. Translation:

  • It enables you to control the positioning of the View on a different axis.

  • translationX — For horizontal positioning.

  • translationY — For vertical positioning.

  • translationZ — Transition value is added to its elevation.

Custom properties

Compose offers a range of custom properties that can be used to achieve additional customization in your UI. However, it’s important to note that these properties need to be extracted and set manually.

Typical custom property set:

custom: {
    background: '#0000FF',
    textColor: '#FFFFFF',
    textSize: 12
}
Enter fullscreen mode Exit fullscreen mode

In brief let's understand the usage of custom properties with one example using text color.

We use the textColor property to apply desired color properties.

You can apply this property directly to the respective children view where you want to make desired changes.

Simply apply hex-color following the hashtag. e.g. #DF1F2D

motion_text: {
        end: ['motion_divider', 'end'],
        top: ['motion_divider', 'bottom', 16],
        custom: {
          textColor: '#2B3784'
        }
      }
Enter fullscreen mode Exit fullscreen mode

You can set the custom properties in a below-mentioned way:

var myCustomProperties = motionProperties(id = "motion_text")

Text(text = "Hello Mind Dots!", modifier = Modifier
    .layoutId(myCustomProperties.value.id())
    .background(myCustomProperties.value.color("background"))
    ,color = myCustomProperties.value.color("textColor")
    ,fontSize = myCustomProperties.value.fontSize("textSize")
)
Enter fullscreen mode Exit fullscreen mode

Debug animation paths

To ensure precise animation MotionLayout provides a debugging facility that would exhibit the animation paths of all the components involved.

To enable debugging we simply can do that with the “debug” parameter.

Note that by default the debug value is set to EnumSet.of(MotionLayoutDebugFlags.NONE)

Here you can see that the paths are denoted by a dotted line.

These dotted lines would come to the rescue especially when you are dealing with complex animation while seeking precision and consistency across devices with different sizes and resolutions.

Now it's the time to dive-in into the code part

  1. Let's start with defining the MotionScene file.

    {
      ConstraintSets: { //Two constraint sets - Start and End
        //1. Collapsed
        start: {
          collapsing_box: {
            width: 'parent',
            height: 200,
            start: ['parent', 'start'],
            end: ['parent', 'end'],
            bottom: ['parent', 'top', -50],
            translationZ: -10,
            alpha: 0
          },
          data_content: {
            top: ['collapsing_box', 'bottom'],
            bottom: ['parent', 'bottom'],
            start: ['parent', 'start'],
            end: ['parent', 'end']
          },
          content_img: {  // Assigned ID for profile pic, which we'll use in the code for the reference
            width: 90,
            height: 142,
            top: ['parent', 'top', 100], //top Constraint => [Constraining to what, where to, Margin value]
            start: ['parent', 'start', 16], //start Constraint
          },
          motion_text: {
            top: ['parent', 'top', 20],
            start: ['parent', 'start', 16],
            translationZ: -7
          },
          piranha_flower: {
            width: 40,
            height: 90,
            top: ['collapsing_box', 'bottom', -70],
            end: ['parent', 'end', 20],
            translationZ: -8
          },
          piranha_tunnel: {
            width: 60,
            height: 100,
            top: ['collapsing_box', 'bottom', -30],
            end: ['parent', 'end', 10],
            translationZ: -8
          }
        },
        //2. Expanded
        end: {
          collapsing_box: {  //Background
            width: 'parent', 
            height: 200,
            start: ['parent', 'start'],
            end: ['parent', 'end'],
            top: ['parent', 'top'],
            translationZ: -10,
            alpha: 1
          },
          content_img: {
            width: 90,
            height: 142,
            top: ['data_content', 'top', -70], 
            start: ['parent', 'start', 4],
          },
          data_content: {
            top: ['collapsing_box', 'bottom'],
            start: ['collapsing_box', 'start'],
            end: ['collapsing_box', 'end']
          },
          motion_text: {
            bottom: ['collapsing_box', 'bottom', 10],
            start: ['content_img', 'end', 2]
          },
          piranha_flower: {
            width: 40,
            height: 90,
            top: ['collapsing_box', 'bottom', 80],
            end: ['parent', 'end', 20],
            translationZ: -10
          },
          piranha_tunnel: {
            width: 60,
            height: 100,
            top: ['collapsing_box', 'bottom', -20],
            end: ['parent', 'end', 10],
            translationZ: -10
          }
        }
      },
      Transitions: {  //to set transition properties between Start and End point.
        default: {
          from: 'start',
          to: 'end',
          pathMotionArc: 'startHorizontal', // Text will move down with slight circular arc
          KeyFrames: {
            KeyAttributes: [  //We define different Attr and how we want this to Animate, during transition for a specific composable
              {
                target: ['content_img'],
                //[collapsed -> expanded]
                frames: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100], //For frames we pass a List containing number between 0 - 100
                rotationZ: [0, 9, 18, 27, 36, 45, 54, 63, 72, 81, 90, 81, 72, 63, 54, 45, 36, 27, 18, 9, 0],  //For dangling effect
                translationX: [0, 9, 18, 27, 36, 45, 54, 63, 72, 81, 90, 81, 72, 63, 54, 45, 36, 27, 18, 9, 0],
                translationY: [0, -14, -28, -42, -56, -70, -84, -98, -112, -126, -130, -126, -112, -98, -84, -70, -56, -42, -28, -14, 0],
                translationZ: [-1.0, -0.9, -0.8, -0.7, -0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]
              },
              {
                target: ['data_content'],
                frames: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100],  //For frames we pass a List containing number between 0 - 100
                translationY: [110, 98, 92, 87, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 2]
              }
            ]
          }
        }
      }
    }
    
  2. Now we have used a scaffold to achieve collapsing feature. For this, we’d require one file to represent the Top bar and another for the rest of the contents.

@Composable
    fun MainScreenContent() {
        val marioToolbarHeightRange = with(LocalDensity.current) {
            MinToolbarHeight.roundToPx()..MaxToolbarHeight.roundToPx()
        }
        val toolbarState = rememberSaveable(saver = MiExitUntilCollapsedState.Saver) {
            MiExitUntilCollapsedState(marioToolbarHeightRange)
        }
        val scrollState = rememberScrollState()
        toolbarState.scrollValue = scrollState.value

        Scaffold(
            modifier = Modifier
                .fillMaxSize(),
            content = {
                MarioMotionHandler(
                    list = populateList(),
                    columns = 2,
                    modifier = Modifier.fillMaxSize(),
                    scrollState = scrollState,
                    progress = toolbarState.progress
                )
            })
    }
Enter fullscreen mode Exit fullscreen mode
  1. Finally add list item content along with the collapsing animation components. Here we’ll be making use of the MotionScene file.
    @Composable
    fun MarioMotionHandler(
        list: List<MiItem>,
        columns: Int,
        modifier: Modifier = Modifier,
        scrollState: ScrollState = rememberScrollState(),
        contentPadding: PaddingValues = PaddingValues(0.dp),
        progress: Float
    ) {
        val context = LocalContext.current
        val chunkedList = remember(list, columns) {
            list.chunked(columns)
        }
        // To include raw file, the JSON5 script file
        val motionScene = remember {
            context.resources.openRawResource(R.raw.motion_scene_mario)
                .readBytes()
                .decodeToString()   //readBytes -> cuz we want motionScene in a String format
        }

        MotionLayout(
            motionScene = MotionScene(content = motionScene),
            progress = progress,
            modifier = Modifier
                .fillMaxSize()
                .background(MarioRedLight)
        ) {

            /**
             * bg - image
             **/
            Image(
                painter = painterResource(id = R.drawable.ic_mario_level),
                contentDescription = "Toolbar Image",
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .layoutId("collapsing_box")
                    .fillMaxWidth()
                    .drawWithCache {
                        val gradient = Brush.verticalGradient(
                            colors = listOf(Color.Transparent, Color.Black),
                            startY = size.height / 3,
                            endY = size.height
                        )
                        onDrawWithContent {
                            drawContent()
                            drawRect(gradient, blendMode = BlendMode.Multiply)
                        }
                    },
                alignment = BiasAlignment(0f, 1f - ((1f - progress) * 0.50f)),
            )

            /**
             * Text - Collapsing
             */
            Text(
                text = stringResource(id = R.string.collapsing_text_minion),
                color = MarioRedDark,
                modifier = Modifier
                    .layoutId("motion_text")
                    .zIndex(1f),
                fontFamily = FontFamily(
                    Font(R.font.super_mario_bros, FontWeight.Light)
                ),
                fontSize = 14.sp
            )

            /**
             * Main image
             **/
            Image(
                painter = painterResource(id = R.drawable.ic_mario_reversed),
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .layoutId("content_img")
                    .clip(RoundedCornerShape(5.dp)),
                contentDescription = "Animating Mario Image"
            )

            /**
             * Grid
             **/
            Column(
                modifier = modifier
                    .verticalScroll(scrollState)
                    .layoutId("data_content")
                    .background(MarioRedLight),
            ) {
                Spacer(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(contentPadding.calculateTopPadding())
                )

                chunkedList.forEach { chunk ->
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .wrapContentHeight()
                    ) {

                        Spacer(
                            modifier = Modifier
                                .fillMaxHeight()
                                .width(contentPadding.calculateStartPadding(LocalLayoutDirection.current))
                        )

                        chunk.forEach { listItem ->
                            GridCharacterCard(
                                miItem = listItem,
                                modifier = Modifier
                                    .padding(2.dp)
                                    .weight(1f)
                            )
                        }

                        val emptyCells = columns - chunk.size
                        if (emptyCells > 0) {
                            Spacer(modifier = Modifier.weight(emptyCells.toFloat()))
                        }

                        Spacer(
                            modifier = Modifier
                                .fillMaxHeight()
                                .width(contentPadding.calculateEndPadding(LocalLayoutDirection.current))
                        )
                    }
                }

                Spacer(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(140.dp)
                )
            }

            /**
             * piranha flower
             **/
            Image(
                painter = painterResource(id = R.drawable.ic_piranha_flower),
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .layoutId("piranha_flower"),
                contentDescription = "Piranha Flower"
            )

            /**
             * piranha tunnel
             **/
            Image(
                painter = painterResource(id = R.drawable.ic_piranha_tunnel),
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .layoutId("piranha_tunnel"),
                contentDescription = "Piranha Tunnel"
            )
        }
    }
Enter fullscreen mode Exit fullscreen mode

Files for character cards of the grid list.

@Composable
    fun GridCharacterCard(
        miItem: MiItem,
        modifier: Modifier = Modifier
    ) {
        Card(
            modifier = modifier.aspectRatio(0.66f),
            shape = RoundedCornerShape(8.dp)
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Gray245)
            ) {
                miItem.itemImage?.let { painterResource(it) }?.let {
                    Image(
                        painter = it,
                        contentDescription = miItem.itemDescription,
                        contentScale = ContentScale.FillWidth,
                        modifier = Modifier
                            .padding(35.dp)
                            .fillMaxWidth()
                    )
                }
                TopBar()
                miItem.itemName?.let { BottomBar(it) }
            }
        }
    }

    @Composable
    private fun BoxScope.TopBar() {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.093f)
                .background(MarioRedDark)
                .padding(horizontal = 8.dp, vertical = 2.dp)
                .align(Alignment.TopCenter)
        ) {
            Row(
                modifier = Modifier
                    .fillMaxHeight(0.75f)
                    .wrapContentWidth()
                    .align(Alignment.CenterStart),
                horizontalArrangement = Arrangement.spacedBy(2.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = Icons.Rounded.Star,
                    contentDescription = "Golden star 1",
                    tint = GoldYellow
                )
                Icon(
                    imageVector = Icons.Rounded.Star,
                    contentDescription = "Golden star 2",
                    tint = GoldYellow
                )
                Icon(
                    imageVector = Icons.Rounded.Star,
                    contentDescription = "Golden star 3",
                    tint = GoldYellow
                )
            }

            Row(
                modifier = Modifier
                    .fillMaxHeight(0.75f)
                    .wrapContentWidth()
                    .align(Alignment.CenterEnd),
                horizontalArrangement = Arrangement.spacedBy(2.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Image(
                    painter = painterResource(id = R.drawable.ic_coin),
                    contentScale = ContentScale.Fit,
                    modifier = Modifier
                        .clip(RoundedCornerShape(5.dp)),
                    contentDescription = "Coin"
                )
                Text(
                    text = "87",
                    color = Color.Black,
                    modifier = Modifier,
                    fontFamily = FontFamily(
                        Font(R.font.super_mario_bros, FontWeight.Normal)
                    ),
                )
            }
        }
    }

    @Composable
    private fun BoxScope.BottomBar(text: String) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.14f)
                .background(MarioRedDark)
                .align(Alignment.BottomCenter)
        ) {
            Text(
                text = text,
                textAlign = TextAlign.Center,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier
                    .fillMaxWidth()
                    .align(Alignment.Center),
                fontFamily = FontFamily(
                    Font(R.font.super_mario_bros, FontWeight.Normal)
                )
            )
        }
    }
Enter fullscreen mode Exit fullscreen mode

To fully grasp the impact of the code snippets, I suggest you take a closer look like Neo did in The Matrix.

Closure

That’s a wrap for now!

I hope this blog has inspired you to explore the endless possibilities of MotionLayout with Jetpack Compose. Keep experimenting and pushing the boundaries of what is possible with this powerful framework.

You can access the source code from Github.
GitHub - Mindinventory/MarioInMotion: Creating a collapsing toolbar with delightful animation in…

Stay tuned for more Jetpack Compose tips and tricks. ✨

Top comments (0)