Collapsing Toolbar Using Jetpack Compose & Motion Layout

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.

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).

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

<?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:layout_height="wrap_content"
        android:text="Mandalorian"
        android:textSize="32sp"
        android:textStyle="bold" />

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

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

and motion scene

<?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:layout_height="wrap_content"
            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_width="wrap_content"
            android:layout_height="wrap_content"
            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_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/title" />
    </ConstraintSet>

    <ConstraintSet android:id="@+id/collapsed">
        <Constraint
            android:id="@id/poster"
            android:layout_height="?attr/actionBarSize"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">
            <CustomAttribute
                app:attributeName="imageAlpha" app:customIntegerValue="0" />
        </Constraint>
        <Constraint
            android:id="@id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:scaleX="0.625"
            android:scaleY="0.625"
            android:textColor="?android:attr/textColorPrimaryInverse"
            app:layout_constraintBottom_toBottomOf="@id/poster"
            app:layout_constraintStart_toStartOf="parent"
            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/recyclerview"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/poster" />
    </ConstraintSet>

</MotionScene>

If you look carefully, you can see that instead of changing the title Text Size, we are scaling it. This is because changing text size in motion animation causes weird glitching and keeps text animation from being smooth.

Now just bind the layout with empty activity and create an adapter for recycler view, and you will get this screen:


And that’s it. Quite easy to make it work without some 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). Well, how we will get this progress? For now, we will skip this step and will just create a composable function that will take the Float progress value and some scrollable content as parameters:

@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",
			modifier = Modifier
				.layoutId("poster")
				.background(MaterialTheme.colors.primary),
			contentScale = ContentScale.FillWidth,
			alpha = 1f - progress // Update alpha based on progress. Expanded -> 1f / Collapsed -> 0f (transparent)
		)
		Text(
			text = "Mandalorian",
			modifier = Modifier
				.layoutId("title")
				.wrapContentHeight(),
			style = MaterialTheme.typography.h6,
			textAlign = TextAlign.Center
		)
		Box(
			Modifier
				.layoutId("content")
		) {
			scrollableBody()
		}
	}
}

So far, it’s looking good. Now we just need to create our Start & End constraint sets. This is where things get a little bit complicated and we get the first hints that Compose Motion Layout is still an experimental API.

When defining constraint sets, we have two options for how to do it, each having some advantages and disadvantages.

The first option is to use Kotlin inline DSL, which allows us to write constraint sets similarly as we would define the constraint layout in Compose. But it only allows us to define constraints and no other parameters, such as color or font size. We would have to handle these parameters on our own using Compose Animation API.

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) {
		width = Dimension.fillToConstraints
		top.linkTo(title.bottom, 8.dp)
		start.linkTo(parent.start)
		end.linkTo(parent.end)
	}
}

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

	constrain(poster) {
		width = Dimension.fillToConstraints
		height = Dimension.value(56.dp)
		start.linkTo(parent.start)
		end.linkTo(parent.end)
		top.linkTo(parent.top)
	}

	constrain(title) {
		start.linkTo(parent.start)
		top.linkTo(parent.top, 8.dp)
		end.linkTo(parent.end)
		bottom.linkTo(poster.bottom)
	}

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

The second option is to define it using Json5 format, which is a disadvantage right from the start  —  who would want to code in JSON?? Also, there is little to no documentation about how this JSON should look like and what parameters can be set. The only source materials I could find are some official examples hidden in the Constraint Layout Repository. I recommend checking it out.

@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: {
		width: "spread",
		start: ['parent', 'start', 0],
		end: ['parent', 'end', 0],
		top: ['title', 'bottom', 16],
	}
} """ )

@Composable
private fun JsonConstraintSetEnd() = ConstraintSet (""" {
	poster: { 
		width: "spread",
		height: 56,
		start: ['parent', 'start', 0],
		end: ['parent', 'end', 0],
		top: ['parent', 'top', 0],
	},
	title: {
		top: ['parent', 'top', 0],
		start: ['parent', 'start', 0],
		end: ['parent', 'end', 0], 
		bottom: ['poster', 'bottom', 0],
		custom: {
			textColor: "#ffffff",
			textSize: 20
        }
	},
	content: {
		width: "spread",
		start: ['parent', 'start', 0],
		end: ['parent', 'end', 0],
		top: ['poster', 'bottom', 0],
	}
                  
} """)

Because we don’t want to handle the color and font size change animation on our own, we will use Constraint sets defined in JSON going forward.

As you can see, in JSON I used custom parameters textColor and textSize. These parameters will be correctly animated between constraint sets based on given progress, but they are not automatically assigned to the linked composable. We will have to do it manually. Luckily, Motion Compose provides functions for retrieving the color from internal motion properties based on its type.

Text(
			text = "Mandalorian",
			modifier = Modifier
				.layoutId("title")
				.wrapContentHeight(),
			color = motionColor("title", "textColor"), // Extracting color value from motionProperties 
			fontSize = motionFontSize("title", "textSize"), // Extracting font size value from motionProperties 
			style = MaterialTheme.typography.h6,
			textAlign = TextAlign.Center
		)

We now have our collapsible header ready, but we still need to figure out the progress. For now, let’s just simulate the progress by using animateFloatAsState, which we can then toggle between 0f and 1f.

var animateToCollapsedState by remember { mutableStateOf(false) }
val progress by animateFloatAsState(
	targetValue = if (animateToEnd) 1f else 0f, // Based on boolean we change progress target
	animationSpec = tween(1000) // specifying animation type - Inbetweening animation with 1000ms duration
)

Next, let’s create Composable with our collapsible header and a simple button, which will toggle between progress states.

@Composable
fun CollapsableToolbar() {
	Box(
		modifier = Modifier
			.fillMaxSize()
	) {
		var animateToCollapsedState by remember { mutableStateOf(false) }
		val progress by animateFloatAsState(
			targetValue = if (animateToEnd) 1f else 0f, // Based on boolean we change progress target
			animationSpec = tween(1000) // specifying animation type - Inbetweening animation with 1000ms duration  
		)
		Column() {
			MotionComposeHeader(progress = progress) {
				//For now we pass no srollable content to header
			}
		}
		Button(onClick = { animateToCollapsedState = animateToCollapsedState.not() },
			Modifier
				.align(Alignment.BottomCenter)
				.padding(16.dp)) {
			Text(text = if (!animateToCollapsedState) "Collapse" else "Expand")
		}
	}
}


This looks pretty nice so far, but we would ideally want to collapse the header on some swipe action and not by using a button.

At first, it seemed to me that this is the only thing that Compose Motion layout is good for  —  animation on some button press-like action — which, fortunately, isn’t true. We can merge it with another great compose feature to unlock its full potential :  Compose Gestures.

CHAPTER 3: SWIPE GESTURE

Compose Gestures’ API provides us with the possibility to detect and track user interaction with a screen. For more details, check out the documentation.

We want to track the swiping gesture on the whole screen and translate this to our already-created motion layout. A Swiping gesture needs two anchors which it can snap. In our case, it should be the top and the bottom of the screen. Since anchors are defined as pixels, we need to know the height of our screen; for this, we use BoxWithConstraints, which provides us with information about its size. Inside, we put another Box composable with a swipeable modifier.

But keep in mind that anchors also define how long of a swipe you will have to do between snaps, so if the height of your expanded header is a lot smaller than the distance between your anchors, you may want to move the anchors closer together so the swipe distance is proportional to the collapsible content.

@Composable
fun CollapsableToolbar() {
	val swipingState = rememberSwipeableState(initialValue = SwipingStates.EXPANDED)

	BoxWithConstraints(modifier = Modifier.fillMaxSize()) {

		val heightInPx = with(LocalDensity.current) { maxHeight.toPx() } // Get height of the available space 

		Box(
			modifier = Modifier
				.fillMaxSize()
				.swipeable(
					state = swipingState,
					thresholds = { _, _ -> FractionalThreshold(0.5f) }, // Treshold defining progress fraction in which should animation automaticly snap to target anchor
					orientation = Orientation.Vertical,
					anchors = mapOf(
						// Maps anchor points (in px) to states
						0f to SwipingStates.COLLAPSED,
						heightInPx to SwipingStates.EXPANDED,
					)
				)
		) {
			Column() {
				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))
			}
		}
	}
}

// Helper class defining swiping State
enum class SwipingStates {
	EXPANDED,
	COLLAPSED
}

Pretty easy, too. Let’s take a look at the result.


Nice! we have a working swipe progress!

CHAPTER 4: ALL TOGETHER

And now, let’s take our collapsible header, put it inside our swipe tracking box and pass its progress to Motion Compose.

But progress generated by Swipe Gesture has a bit of a different format than Motion Compose. SwipeProgress has the following declaration.

class SwipeProgress<T>(
	val from: T,
	val to: T,
	/*@FloatRange(from = 0.0, to = 1.0)*/
	val fraction: Float
)

The fraction is defining the animation progress, meaning that the starting animation always has the value 0f and the finished value 1f, and from & to parameters are changed according to the current animation direction  —  as is clearly visible in the GIF above.

But Motion Compose is defined just as a single float that is iterating between 0 (collapsed state) and 1 (expanded state).

So, we just use the following simple logic to convert SwipeProgress into a single float which can be passed to Motion Compose.

MotionLayoutHeader( // Converting swiping progress to fot into 0f-1f format of MotionCompose
  progress =  if (swipingState.progress.to == SwipingStates.COLLAPSED) swipingState.progress.fraction 
              else 1f - swipingState.progress.fraction
) 

And voila!


The last thing we will do is add scrollable content under the collapsible header. Earlier, we prepared the scrollableBody parameter for MotionComposeHeader composable. The trickier part is to handle when we should collapse/expand the header and when to scroll the body. Well, to manage this, we just need to use the nestedScroll modifier together with the swipeable modifier on the parent box.

.nestedScroll(connection)

Then define connection as follows:

val connection = remember {
  object : NestedScrollConnection {

    override fun onPreScroll( // Desides if use the sroll for parent (Swipe) or pass it to the childern 
      available: Offset,
      source: NestedScrollSource
    ): Offset {
      val delta = available.y
      return if (delta < 0) {
        swipingState.performDrag(delta).toOffset()
      } else {
        Offset.Zero
      }
    }

    override fun onPostScroll( // If there is any leftover sroll from childern, let's try to use it on parent swipe
      consumed: Offset,
      available: Offset,
      source: NestedScrollSource
    ): Offset {
      val delta = available.y
      return swipingState.performDrag(delta).toOffset()
    }

    override suspend fun onPostFling( // Lets's try to use fling on parent and pass all leftover to childern 
      consumed: Velocity,
      available: Velocity
    ): Velocity {
      swipingState.performFling(velocity = available.y)
      return super.onPostFling(consumed, available)
    }

    private fun Float.toOffset() = Offset(0f, this)
  }
}

Connection is an implementation of the interface NestedScrollConnection.

It provides a bridge between the scrollable children and their parents. For more details and a source on how it works, I recommend checking out How to master Swipeable and NestedScroll Modifiers in Jetpack Compose.

Well, that’s it. We have now finished our scrollable collapsing toolbar.


Check out the complete code in this GitHub.

CHAPTER 5: ISSUES

There are some issues that I didn’t mention in the previous chapters but that should be pointed out.

The first issue is that I couldn’t figure out how to set the height of the scrollable content to match the constraint. So, the scrollable content height always matches its parent height. This results in the bottom of the scrollable content being cut (the last item isn’t visible after the full scroll down). This can be fixed simply by adding scrollable content bottom padding by the height of the collapsed header. This compensates for the space that the header is occupying and takes it from scrollable content.

The second issue is similar to the one I mentioned in the Motion Layout chapter. Same as in Motion Layout, the text size animation is a bit glitchy. In the Motion Compose example GIF, you can see that the text size animation isn’t as smooth as in the Motion Layout example. So, ideally, you would probably prefer animating the text size by scaling it, using Compose Animation API. But for the simplicity of this example, we settled with not that smooth of an animation.

CHAPTER 6: CONCLUSION

At first glance, Compose Motion Layout seems like a not very useful and unfinished tool. But when combined with Compose gestures, it opens up a lot of possibilities on how to compose your app layout. It still has a lot of drawbacks — like the lack of possibility to set custom parameters in DSL constraint set declaration; if you need them, you have to use JSON. But we have to keep in mind that we are still working with experimental API, which will hopefully get much better, properly documented and overall more developer-friendly soon.

REACH OUT

Share Article
Jiri Parizek

Jiri Parizek

Android developer

You might also like...