# Collapsing Toolbar with Jetpack Compose & Motion Layout

[Jiri Parizek](https://www.strv.com/blog/authors/jiri) Android Engineer

---

When Jetpack Compose started to form, the future of our beloved Motion Layout was unclear. Fortunately, the Compose Constraint layout library offers us the experimental implementation of motion layout. So, let’s try to use the Jetpack Compose to build a good old collapsing layout.

Below is the Collapsing toolbar that we're going to build.

Let's define some terminology first:  
**Motion Layout** = Motion Layout API for old view system  
**Motion Compose** = Motion Layout API for Jetpack Compose

---

To run using Motion Compose in your Compose-ready project, you will just need to import the Compose Constraint layout library.

```gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
```

I want to point out that this entire article is linked to version **1.0.0-rc01**. At the time of writing this example, the whole Motion compose was still an experimental API — so some information shared here might not be relevant anymore. :)

## Chapter 1: Motion Layout

Let’s first take a little detour and quickly build the Collapsing toolbar with Motion Layout. I expect you know a bit about Motion Layout already and I’ll skip the explanation of what is happening in all the XML. If you haven’t had the opportunity to try out Motion Layout, I recommend checking out the [Introduction to MotionLayout (part I)](https://medium.com/google-developers/introduction-to-motionlayout-part-i-29208674b10d?ref=strv.ghost.io).

Now, let’s get to it. First, simply define the motion layout:

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/motion_scene">

    <ImageView
        android:id="@+id/poster"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:background="@color/colorPrimary"
        android:contentDescription="@null"
        android:fitsSystemWindows="true"
        android:scaleType="center"
        android:src="@drawable/poster" />

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:text="Mandalorian"
        android:textSize="32sp"
        android:textStyle="bold" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_height="0dp" />

</androidx.constraintlayout.motion.widget.MotionLayout>
```

And the motion scene:

```xml
<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">

<Transition
    app:constraintSetEnd="@id/collapsed" 
    app:constraintSetStart="@id/expanded">
    <OnSwipe
        app:dragDirection="dragUp" 
        app:touchAnchorId="@id/recyclerview" 
        app:touchAnchorSide="top" />
</Transition>

<ConstraintSet android:id="@+id/expanded">
    <Constraint
        android:id="@+id/poster"
        android:alpha="1.0"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <CustomAttribute
            app:attributeName="imageAlpha" 
            app:customIntegerValue="255" />
    </Constraint>

    <Constraint
        android:id="@+id/title"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="24dp"
        android:scaleX="1.0"
        android:scaleY="1.0"
        app:layout_constraintTop_toBottomOf="@id/poster"
        app:layout_constraintStart_toStartOf="parent">
        <CustomAttribute
            app:attributeName="textColor" 
            app:customColorValue="?android:attr/textColorPrimary" />
    </Constraint>

    <Constraint
        android:id="@+id/recyclerview"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toBottomOf="@id/title" />
</ConstraintSet>

<ConstraintSet android:id="@+id/collapsed">
    <Constraint
        android:layout_height="?attr/actionBarSize"
        app:attributeName="imageAlpha" 
        app:customIntegerValue="0"
        android:scaleX="0.625"
        android:scaleY="0.625"
        android:textColor="?android:attr/textColorPrimaryInverse"
        app:layout_constraintBottom_toBottomOf="@id/poster"
        app:layout_constraintTop_toTopOf="@id/poster"
        app:layout_constraintEnd_toEndOf="parent">
        <CustomAttribute
            app:attributeName="textColor" 
            app:customColorValue="?android:attr/textColorPrimaryInverse" />
    </Constraint>
    <Constraint
        android:id="@+id/title"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="24dp"
        android:textColor="?android:attr/textColorPrimaryInverse"
        app:layout_constraintTop_toBottomOf="@id/poster" />
</ConstraintSet>
</MotionScene>
```

Notably, instead of changing the TextSize, we scale the title. Changing text size directly causes glitches; scaling keeps the animation smooth.

Now, just bind the layout with an empty activity and create an adapter for RecyclerView, and you'll get this screen:

*And that’s it. Quite easy to make it work without hacks and tricks.*

---

## Chapter 2: Motion Compose

Now let’s have a look at the Compose counterpart.

The first biggest difference between Motion Layout and Motion Compose is that Motion Compose doesn’t provide any **onSwipe** or **onClick** modifiers as Motion Layout, which would define when and how a transition should happen. We only have the option to set the value of the transition **progress**.

This progress is defined as a Float value between **0** (Start Constraint) and **1** (End Constraint). How to get this progress? For now, we create a composable function that takes the Float progress value and some scrollable content:

```kotlin
@Composable
fun MotionComposeHeader(progress: Float, scrollableBody: @Composable () -> Unit) {
    MotionLayout(
        start = //TODO,
        end = //TODO,
        progress = progress,
        modifier = Modifier.fillMaxWidth()
    ) {
        Image(
            painter = painterResource(id = R.drawable.poster),
            contentDescription = "poster",
            layoutId("poster"),
            background(MaterialTheme.colors.primary),
            contentScale = ContentScale.FillWidth,
            alpha = 1f - progress
        )
        Text(
            text = "Mandalorian",
            layoutId("title"),
            wrapContentHeight(),
            style = MaterialTheme.typography.h6,
            textAlign = TextAlign.Center
        )
        Box(
            Modifier.layoutId("content")
        ) {
            scrollableBody()
        }
    }
}
```

So far, so good. Next, define your Start & End constraint sets. This part is tricky, as Motion Compose is still experimental.

### Defining Constraint Sets

**Option 1:** Use Kotlin inline DSL, which is similar to defining constraints in Compose. But it only handles constraints, not other parameters like color or font size. You’ll handle those separately.

```kotlin
private fun startConstraintSet() = ConstraintSet {
    val poster = createRefFor("poster")
    val title = createRefFor("title")
    val content = createRefFor("content")

    constrain(poster) {
        width = Dimension.fillToConstraints
        start.linkTo(parent.start)
        end.linkTo(parent.end)
        top.linkTo(parent.top)
    }
    constrain(title) {
        start.linkTo(parent.start, 16.dp)
        top.linkTo(poster.bottom, 16.dp)
    }
    constrain(content) {
        top.linkTo(title.bottom, 8.dp)
        end.linkTo(parent.end)
    }
}

private fun endConstraintSet() = ConstraintSet {
    val poster = createRefFor("poster")
    val title = createRefFor("title")
    val content = createRefFor("content")

    height = Dimension.value(56.dp)
    top.linkTo(parent.top, 8.dp)
    bottom.linkTo(poster.bottom)
    top.linkTo(poster.bottom, 8.dp)
    end.linkTo(parent.end)
}
```

**Option 2:** Define constraints via JSON5 format. This is more cumbersome and less documented, but allows more flexibility for parameters like color and size.

Example JSON constraints:

```kotlin
@Composable
private fun JsonConstraintSetStart() = ConstraintSet("""{
    poster: {
        width: "spread",
        start: ['parent', 'start', 0],
        end: ['parent', 'end', 0],
        top: ['parent', 'top', 0],
    },
    title: {
        top: ['poster', 'bottom', 16],
        start: ['parent', 'start', 16],
        custom: {
            textColor: "#000000",
            textSize: 40
        }
    },
    content: {
        top: ['title', 'bottom', 16],
    }
}""")
```

Similarly for end constraints.

Because of the complexity, we'll prefer JSON-defined constraint sets, with custom parameters like `textColor` and `textSize`.

### Accessing custom parameters

Use MotionCompose functions:

```kotlin
Text(
    color = motionColor("title", "textColor"),
    fontSize = motionFontSize("title", "textSize"),
)
```

### Animating progress

Simulate progress with `animateFloatAsState`:

```kotlin
var animateToEnd by remember { mutableStateOf(false) }
val progress by animateFloatAsState(
    targetValue = if (animateToEnd) 1f else 0f,
    animationSpec = tween(1000)
)
```

### Toggle header with button

```kotlin
@Composable
fun CollapsableToolbar() {
    Box(Modifier.fillMaxSize()) {
        var animateToEnd by remember { mutableStateOf(false) }
        val progress by animateFloatAsState(
            targetValue = if (animateToEnd) 1f else 0f,
            animationSpec = tween(1000)
        )
        Column {
            MotionComposeHeader(progress = progress) {
                // no scrollable content for now
            }
            Button(
                onClick = { animateToEnd = !animateToEnd },
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .padding(16.dp)
            ) {
                Text(text = if (!animateToEnd) "Collapse" else "Expand")
            }
        }
    }
}
```

It looks pretty nice so far, but ideally you'd want to collapse the header on swipe instead of a button.

By combining with Compose Gestures, you unlock full potential: [Compose Gestures docs](https://developer.android.com/jetpack/compose/gestures?ref=strv.ghost.io).

## Chapter 3: Swipe Gesture

Compose Gestures API detects and tracks user interaction. See [gesture documentation](https://developer.android.com/jetpack/compose/gestures?ref=strv.ghost.io).

You can detect swipe gestures on the whole screen, mapping them to your motion layout. Use `BoxWithConstraints` to get screen size, then attach a `swipeable` modifier to another Box:

```kotlin
@Composable
fun CollapsableToolbar() {
    val swipingState = rememberSwipeableState(initialValue = SwipingStates.EXPANDED)
    BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
        val heightInPx = with(LocalDensity.current) { maxHeight.toPx() }
        Box(
            modifier = Modifier
                .fillMaxSize()
                .swipeable(
                    state = swipingState,
                    thresholds = { _, _ -> FractionalThreshold(0.5f) },
                    orientation = Orientation.Vertical,
                    anchors = mapOf(
                        0f to SwipingStates.COLLAPSED,
                        heightInPx to SwipingStates.EXPANDED
                    )
                )
        ) {
            Text(text = "From: ${swipingState.progress.from}", modifier = Modifier.padding(16.dp))
            Text(text = "To: ${swipingState.progress.to}", modifier = Modifier.padding(16.dp))
            Text(text = swipingState.progress.fraction.toString(), modifier = Modifier.padding(16.dp))
        }
    }
}
```

Switching header state via swipe is now straightforward.

## Chapter 4: All Together

Embed the swipe-based progress into the header:

```kotlin
MotionLayoutHeader(
    progress = if (swipingState.progress.to == SwipingStates.COLLAPSED)
        swipingState.progress.fraction
    else
        1f - swipingState.progress.fraction
)
```

Finally, add scrollable content beneath the header, managing when to collapse/expand and scroll using `nestedScroll`:

```kotlin
val connection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val delta = available.y
            return if (delta < 0) {
                swipingState.performDrag(delta).toOffset()
            } else {
                Offset.Zero
            }
        }
        override fun onPostScroll(consumed: Offset, available: Offset): Offset {
            return swipingState.performDrag(available.y).toOffset()
        }
        override suspend fun onPostFling(velocity: Velocity, available: Velocity): Velocity {
            swipingState.performFling(velocity = available.y)
            return super.onPostFling(consumed, available)
        }
        private fun Float.toOffset() = Offset(0f, this)
    }
}
```

More details in [Master Swipeable and NestedScroll Modifiers in Jetpack Compose](https://proandroiddev.com/how-to-master-swipeable-and-nestedscroll-modifiers-in-compose-bb0635d6a760?ref=strv.ghost.io).

---

## Chapter 5: Issues

- Scrollable content height mismatch; fix by padding bottom equal to collapsed header height.
- Text size animation can be glitchy; better to animate scaling with Compose Animation API.

## Chapter 6: Conclusion

Compose Motion Layout is initially rough but combined with gestures it opens exciting layout possibilities. Its limitations like lack of custom parameter support in DSL will improve over time. We’re working with an experimental API that will hopefully become more robust and better documented soon.

---

**Don't miss anything**