3.
Building Layout Groups in Compose
In this chapter, you’ll learn about layouts in Jetpack Compose. Each layout has a specific usecase, and you’ll learn how to pick the right one for the UI you want to build. Finally, you’ll group composable functions inside different kinds of layouts to make a more complex UI.
In the previous chapter, you focused on displaying the elements onscreen; this time, you’ll focus on positioning those elements.
As always, it’s best to start with the basics. Read on to discover what the Jetpack Compose replacements for the basic layouts in Android are.
Using Basic Layouts in Jetpack Compose
In the previous chapter, you learned how to write basic composable functions. The next step is to build a more complex UI by positioning those elements in a specific way—arranging them.
When working with XML, you achieve that by using a layout, a class that extends ViewGroup. ViewGroup can hold zero or more views and is responsible for measuring all of its children and positioning them on the screen according to different rules.
In Jetpack Compose, the replacement for ViewGroup is called Layout. Look at the source code to understand how Layout() works:
@Composable inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
There are two important parameters here:
-
content: A composable function that holds children of the
Layout. - measurePolicy: Responsible for defining measuring and layout behavior.
Measuring and positioning the elements is a complex job. That’s why Jetpack Compose offers predefined layout types that handle this for you.
Every implementation of these predefined layouts has its own logic for positioning the children. With this in mind, there are layouts that order items vertically or horizontally, layouts that build complex UI with navigation drawers and simpler layouts, which stack children vertically. All of those layouts use measurePolicy to position items in different ways, so you don’t have to do it yourself!
When thinking about basic layouts, the first thing that might come to mind is a LinearLayout. Your next step is to learn about LinearLayout’s composable counterpart.
Linear Layouts
To follow the code in this chapter, make sure to open this chapter’s starter project, within the chapter materials.
A LinearLayout characteristically positions its children in a linear flow. This flow is called an orientation and can be horizontal or vertical. In Jetpack Compose, there are two different composable functions that replace LinearLayout, one for each orientation. You’ll start with the horizontal version—a Row.
Using Rows
Open RowScreen.kt and look inside. You’ll see an empty composable function, MyRow(), where you’ll write your code. You’ll add a Row, a LinearLayout counterpart, when it comes to horizontal layouts.
Start by replacing MyRow() with the following code:
@Composable
fun MyRow() {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxSize()) {
THREE_ELEMENT_LIST.forEach { textResId ->
Text(
text = stringResource(id = textResId),
fontSize = 18.sp
)
}
}
}
Here, you added a Row() with several different parameters.
You used Alignment.CenterVertically to center the children vertically, Arrangement.SpaceEvenly for each child to have an equal amount of space in between and Modifier.fillMaxSize() to make the layout fill the entire screen.
That final step is important because otherwise, a Row would take up only the space it needs to draw its children, since none of its children have weight defined. Without filling the screen size, the arrangement and alignment wouldn’t matter and all items would be placed at the top-left of the screen, one after another. You’ll learn more about weights in a moment.
Inside the Row(), you placed three Texts using a predefined list that holds string resources. You also increased their font size for readability.
Build and run, then tap on the Row button from the navigation menu and take a look at the screen:
You can see three text fields that are centered vertically and arranged so there’s equal spacing on all sides.
Note: This is a good time to experiment with different arrangements and observe their result.
Now that you know how to position elements horizontally inside a row, it’s time to explore the Row() signature, to see what else you can do.
Exploring Rows
Open the Row() signature, to look at what you can do with it:
@Composable
inline fun Row(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
content: @Composable RowScope.() -> Unit
)
As you see, there are two new parameters for you to work with: horizontalArrangement and verticalAlignment.
You use arrangements to position children relative to one another. The possible horizontal arrangements are:
-
SpaceBetween: The
Row()places each child with an equal amount of space, without calculating in spacing before the first child, or after the last child. -
SpaceEvenly: Similar to
SpaceBetween, theRow()places the children with an equal amount of space, but this time it includes starting or ending spacing. -
SpaceAround: The
Row()places children just like withSpaceEvenly, but reduces the space between consecutive children by half. -
Center, Start, End: The
Row()places children at the center, start or end without space between them.
Using Alignment you position the children in a specific way within the parent. Specifically, verticalAlignment aligns the children vertically in three different ways:
- Top: Aligns the children to the top of the parent.
- CenterVertically: Aligns the children in the center of the parent, vertically.
- Bottom: Aligns the children to the bottom of the parent.
The final way to position children inside a Row is by using weights. To add weights, you need to use a special way to access the weight() modifier from Compose. In the above example with three Text elements, you could use it like so:
@Composable
fun RowScope.MyRow() { // This composable is called from inside the Row
Text(
modifier = Modifier.weight(1 / 3f), // here
...
)
}
You can set the weight of a composable by using a modifier parameter. The weight can only be set inside a RowScope which is a scope for the children of a Row(). If you’re writing code directly inside the Row(), you can use Modifier.weight() without extra code.
If you need to make a custom composable that you use inside a Row, your composable needs to be an extension function of the RowScope like in the example above. Note that in this case, you won’t be able to use the composable outside of a Row().
Within weight() you define how big of a fraction of the parent the child will take up. In this case, you gave each child a third of the parent! :]
If a child doesn’t have a weight, the Row will calculate its width using the preferred width first, e.g. using the size() modifier. It will then calculate the sizes of the children with weights, proportionally to their weight, based on the remaining available space.
This means if there is an element taking up 200dp in width, and you use weights, the weighted children will take up the screen width, minus the 200dp that’s already taken. If none of the children have weight, the Row() will be as small as possible to fit all its children without spacing.
Rows represent horizontal arrangement of items, and such is its name. Following this logic, a vertical arrangement of items is called a Column. Let’s see how to use it!
Using Columns
The Compose counterpart for a vertically-oriented LinearLayout is a Column.
Open ColumnScreen.kt and you’ll see a similar situation as before—an empty MyColumn(), which you’ll implement.
Fill in the function, so it looks like this:
@Composable
fun MyColumn() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxSize()
) {
THREE_ELEMENT_LIST.forEach { textResId ->
Text(
text = stringResource(id = textResId),
fontSize = 22.sp
)
}
}
}
The Column() implementation is the same as the Row(), except you swapped the arrangements and alignments from horizontal to vertical. This is because the items are already placed vertically in a Column, and you need to define how they behave horizontally and how to space them vertically. With Rows, the situation is the opposite—items are placed horizontally, and the Row needs to know how they behave vertically and how to space them horizontally.
Build and run and select the Column button from the navigation menu.
This time, you see that the items are arranged vertically instead of horizontally, with the same spacing.
Now, you’ve seen how Column and Row are similar to LinearLayouts. They’re more powerful, however, because you can arrange the children in several different ways—which the LinearLayout doesn’t allow.
Exploring Columns
Now you’ve learned how to use Columns, check how they differ from a Row, by opening the Column() signature:
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
)
As you learned before, the parameters are almost the same, but take a closer look and you’ll see that the layout swaps the arrangements and alignments. This means you can do all the same things inside the Column as in a Row, but with different orientations.
Next, you’ll learn about a composable counterpart for a FrameLayout, called a Box.
Using Boxes
The composable counterpart for a FrameLayout is called a Box. Just like FrameLayout, it’s used to display children relative to their parent’s edges, and allows you to stack children. This is useful when you have elements that need to be displayed in those specific places or when you want to display elements that overlap, such as dialogs.
Now, open BoxScreen.kt and you’ll find the usual empty function,MyBox(). Add the following code to complete it:
@Composable
fun MyBox(
modifier: Modifier = Modifier,
contentModifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
Text(
text = stringResource(id = R.string.first),
fontSize = 22.sp,
modifier = contentModifier.align(Alignment.TopStart)
)
Text(
text = stringResource(id = R.string.second),
fontSize = 22.sp,
modifier = contentModifier.align(Alignment.Center)
)
Text(
text = stringResource(id = R.string.third),
fontSize = 22.sp,
modifier = contentModifier.align(Alignment.BottomEnd)
)
}
}
This time, the function has two parameters—a modifier and a contentModifier, with default arguments of Modifier, the empty modifier implementation. This way, you can pass in custom modifiers that will change how the parent Box or each piece of content behaves. After which each element can chain more modifier function calls, to apply additional customization.
This is a good practice, as you can pass in a custom modifier that applies padding or styling to the parent modifier, and then reuse custom styles throughout your app, while the end component adds a bit more customization, based on the component. You can do the same for content based modifiers.
Here, the Box() has three text fields, as in previous examples, and uses thealign modifier to position those text fields in three different places.
Build and run, then select the Box option from the navigation menu to see the result:
The text fields appear diagonally across the screen, with the first one at the top-left corner, the second one in the center and the last one at the bottom-right corner.
Using a Box is really useful in specific situations, and they make positioning elements incredibly easy.
Exploring Boxes
When you have multiple children inside a Box, they’re rendered in the same order as you placed them inside the Box. Here’s the implementation:
@Composable
fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
)
contentAlignment allows you to set the default Alignment to its children. If you want to have different Alignments between each child, you need to set Alignment by using Modifier.align() on a child.
propagateMinConstraints defines if the minimal constraints should be passed and used for the content too. By default, the constraints of the Box() won’t be taken into account when measuring the children.
You can set the Alignment to any edge of the screen as well as in relation to the center, using any of the following types of alignment:
-
TopStart
-
TopCenter
-
TopEnd
-
CenterStart
-
Center
-
CenterEnd
-
BottomStart
-
BottomCenter
-
BottomEnd
Where each of the alignments refers to which part of the screen the Box will attach an item to.
Next, you’ll learn about one of the first layouts introduced in Jetpack Compose: the Surface.
Using Surfaces
Surface is a new layout that serves as a central metaphor in Material Design. What’s unique about Surface is it can only hold one child at a time, but it provides many style treatments for the content of its children, such as the elevation, border and more.
It’s time to see the Surface in action. Open SurfaceScreen.kt and look at the contents:
@Composable
fun SurfaceScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
MySurface(modifier = modifier.align(Alignment.Center))
}
BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}
@Composable
fun MySurface(modifier: Modifier) {
//TODO write your code here
}
To show all that the Surface can do, the example is set inside a full-screen Box() and an Alignment.Center. All that’s left is to implement the empty MySurface(). To do this, add the following code to finish it:
@Composable
fun MySurface(modifier: Modifier) {
Surface(
modifier = modifier.size(100.dp), // 1
color = Color.LightGray, // 2
contentColor = colorResource(id = R.color.colorPrimary), // 2
elevation = 1.dp, // 3
border = BorderStroke(1.dp, Color.Black) // 4
) {
MyColumn() // 5
}
}
There are many small steps in this code, so go over them one by one:
- You first set the size of the surface to
100dpin both height and width usingModifier.size(). - Then you set the color of the surface to
Color.LightGrayand the color of its content tocolorPrimary. The surface will be gray, andSurfacewill set thecontentColorto all the elements it applies to—such asTextelements. - You add an elevation of
1dpto raise theSurfaceabove other elements. - You also add a black border to outline the
Surface. - Finally, you set the child to the
Surfaceto be theMyColumn()you defined earlier.
This is a perfect example of the power of Jetpack Compose. You can reuse each of the screens and composable functions you implemented before. This time, you reused MyColumn(), with three vertical Text elements.
Build and run and select Surface from the navigation menu.
At the center of the screen is the Surface in a light gray color with a black border. Within it is the previously-implemented custom Column.
Previously, all the Text elements used the default, black color, but using contentColor you changed the text color of Column’s children to use a green color.
If you’re reading the grayscale version of the book, you might not notice the color change as easily, so make sure to build and run the app and preview the changes directly on your phone!
Now let’s see what else the Surface() allows you to do.
Exploring Surfaces
To see what else a Surface() has to offer, open its signature:
@Composable
fun Surface(
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
color: Color = MaterialTheme.colors.surface,
contentColor: Color = contentColorFor(color),
border: BorderStroke? = null,
elevation: Dp = 0.dp,
content: @Composable () -> Unit
)
These parameters define Surface’s purpose. There are five purposes in total:
-
Shape: Clips the children with the defined
shape. -
Color: Fills the shape with a
coloryou define. - Border: Draws borders, if they’re set.
- Elevation: Sets the elevation and draws an appropriate shadow.
-
Content: Sets the default color for its content with the defined
contentColor.
The most common way to use a Surface is as the root layout of your components. Since it can hold only one child, that child is usually another layout that positions the rest of the elements. The Surface() doesn’t handle positioning—its child does.
Note: There’s a popular custom
Surfaceimplementation calledCard. ACardhas exactly the same five purposes and can only hold one child. The only difference between theCardand aSurfaceare its default parameters. ACardhas a predefined elevation and uses a material theme shape with rounded corners.
Now that you’ve learned all the basic layouts available in Jetpack Compose, your next step is to learn about a more advanced layout that lets you create a fully-functional UI. That element is called a Scaffold.
Scaffold
The Scaffold is a new layout that Jetpack Compose introduced. You use it to implement a visual layout following the standard Material Design structure you are all familiar with by now. It combines several different material components to construct an entire screen. Because the Scaffold() offers multiple ways to build your UI, it’s best to jump into the code, and play around with it!
Using Scaffold
Open ScaffoldScreen.kt and look inside. You’ll see three empty composable functions:
@Composable
fun MyScaffold() {
//todo write your code here
}
@Composable
fun MyTopAppBar(scaffoldState: ScaffoldState) {
//todo write your code here
}
@Composable
fun MyBottomAppBar() {
//todo write your code here
}
You’ll use these empty functions to implement your own Scaffold and to add top and bottom app bars.
Start by entering the following code inside MyScaffold(). It should look like so:
@Composable
fun MyScaffold() {
val scaffoldState: ScaffoldState = rememberScaffoldState()
val scope: CoroutineScope = rememberCoroutineScope()
Scaffold(
scaffoldState = scaffoldState,
contentColor = colorResource(id = R.color.colorPrimary),
content = { MyRow() },
topBar = { MyTopAppBar(scaffoldState = scaffoldState, scope = scope) },
bottomBar = { MyBottomAppBar() },
drawerContent = { MyColumn() }
)
}
First, you create the scaffold state by calling rememberScaffoldState(), then you assign it to the Scaffold(). You set contentColor to the primary color of the app and content to MyRow(), which you previously implemented.
Then you create a scope, using rememberCoroutineScope(). You need to use coroutines to trigger certain Scaffold() behavior, such as opening and closing the drawers.
Next, you set the top and bottom bar content to the composables you haven’t implemented yet. Here you pass the scaffoldState and the scope that’ll be used by coroutines.
Finally, you set MyColumn() as your drawer. There’s a lot going on here, but you’ll see how it connects together in a moment.
Make sure to update the MyTopAppBar signature to the following:
@Composable
fun MyTopAppBar(scaffoldState: ScaffoldState, scope: CoroutineScope) {}
Build and run, then click the Scaffold option from the navigation menu.
You can see the MyRow() with the three Text elements on the screen, but the top and bottom app bars aren’t showing. That’s not surprising since you haven’t implemented them yet. :]
Additionally, you can open the navigation drawer by swiping from the left to the right side of the screen, to show the MyColumn from before.
Now let’s finish the screen by implementing the top and bottom bars.
Completing the Screen
To complete the screen, implement the two remaining composables. Add the following code to complete MyTopAppBar():
@Composable
fun MyTopAppBar(scaffoldState: ScaffoldState, scope: CoroutineScope) {
val drawerState = scaffoldState.drawerState
TopAppBar(
navigationIcon = {
IconButton(
content = {
Icon(
Icons.Default.Menu,
tint = Color.White,
contentDescription = stringResource(R.string.menu)
)
},
onClick = {
scope.launch { if (drawerState.isClosed) drawerState.open() else drawerState.close() }
}
)
},
title = { Text(text = stringResource(id = R.string.app_name), color = Color.White) },
backgroundColor = colorResource(id = R.color.colorPrimary)
)
}
First, you create a new value called drawerState, using the scaffoldState. You’ll use this to access the Scaffold’s drawer.
Then, you add an existing predefined implementation of the TopAppBar(). You add an IconButton() with the Menu icon and White content color as the navigationIcon.
For the click action of the IconButton(), you initiate opening the drawer by changing the drawerState inside the scaffoldState.
To change the drawerState, you must do it from a coroutine or another suspend function. In this case you launch a coroutine by using the scope passed from the parent composable. This will open the drawer whenever you click the menu icon.
The navigationIcon is a predefined parameter you can use to define the first element in the TopAppBar, which usually represents a Home or Back button.
Then you define the title, represented by a simple Text element that shows the app name. Showing a title in the top bar is common pattern for most Android apps.
The TopAppbar is a pretty straightforward component that lets you define an elevation, a backgroundColor, title and navigationIcon separately, or combined in a single content function, and special actions that define another composable function for menu actions. Play around with other parameters to see how they style the TopAppBar.
Now, implement the MyBottomAppBar():
@Composable
fun MyBottomAppBar() {
BottomAppBar(
content = {},
backgroundColor = colorResource(id = R.color.colorPrimary))
}
For MyBottomAppBar(), you add an existing implementation of the bottom app bar with empty content. You also define colorPrimary as the background color.
Build and run again and check the changes to the screen:
The screen now shows top and bottom bars as well. The top bar contains the Menu icon, which opens the drawer when the user clicks it.
The drawer shows the three text fields from MyColumn(). To dismiss the drawer, just click anywhere on the screen, outside of the drawer, or swipe the drawer left.
You’ve now learned how to group your composables inside layouts, to position them and to give them common properties. You can achieve this by using either multiple basic layouts or one of the advanced layouts.
Aside from Scaffold, advanced layouts also include a ConstraintLayout. ConstraintLayout lets you position children by defining constraints between them, just like the version you find in XML.
You’ll dive deep and learn more about ConstraintLayout in Chapter 9, “Using ConstraintSets in Composables”. But before all that, let’s explore the Scaffold() signature.
Exploring Scaffold
To learn more about all the parameters the Scaffold() lets you use, open its signature:
@Composable
fun Scaffold(
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState = rememberScaffoldState(),
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
isFloatingActionButtonDocked: Boolean = false,
drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
drawerGesturesEnabled: Boolean = true,
drawerShape: Shape = MaterialTheme.shapes.large,
drawerElevation: Dp = DrawerDefaults.Elevation,
drawerBackgroundColor: Color = MaterialTheme.colors.surface,
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
drawerScrimColor: Color = DrawerDefaults.scrimColor,
backgroundColor: Color = MaterialTheme.colors.background,
contentColor: Color = contentColorFor(backgroundColor),
content: @Composable (PaddingValues) -> Unit
)
You can see that it has lots of features and components. This breakdown will give you a clear picture of what each of them do, and how to use them:
-
scaffoldState: The state of the layout. Unlike the basic layouts,
Scaffoldrequires custom handling of its state. This is important because it can hold several different components that can change its visibility or content. A common example of handling state is changing whether a drawer displays or not. - topBar: A composable that renders the top app bar. While you could create a custom composable, Jetpack Compose offers you a predefined composable to save you from reinventing the wheel.
-
bottomBar: This composable renders the bottom app bar. As with the previous parameter, you can choose whether to use a custom composable or the predefined
BottomAppBar. -
snackbarHost: As the name implies, this component hosts a
SnackBar. It handles the state that determines whenSnackBars should be shown. -
floatingActionButton: Lets you set a composable for the main
FloatingActionButtonon the screen. The defaultFloatingActionButtonis recommended for consistency with Material Design specs. - drawerContent: Use this composable for drawers that require a custom implementation.
-
content: A composable shown inside
Scaffold. This is where you put the main content of the screen.
There are also many other parameters which are less important, but you can explore them if you want to play around with your Scaffold.
Now you can move onto building more components in Compose and complex UI, with the knowledge you gained in the first few chapters! :]
Key Points
-
Use
Layoutsto group and position your elements on the screen. -
Rowlets you position elements horizontally on the screen. -
Columnlets you position elements vertically on the screen. -
Use vertical or horizontal
Arrangementto change the position of elements inside theRoworColumn. -
Use weights to change the proportion of the screen your elements will use.
-
Boxallows you to vertically stack the elements on top of each other. -
Within
Row,Column,Box, or other functions, you gain access to hidden modifiers from theRowScope,ColumnScope,BoxScopeand other scope types, respectively. -
If you want to build components that should only be used within
Row,Boxor other grouping composables, you can make them an extension function to the appropriateRowScope,BoxScopeor other scopes, respectively. -
Making an extension function composable to any
Scopegives you access to theScope’s modifiers in the function. -
Group multiple basic layouts to create a more complex screen.
-
Use
Surfaceto clip the elements inside it with an option to add the border and elevation. -
Surfacecan hold only one child. -
Add another layout inside
Surfaceto position the elements. -
Cardis a just aSurfacewith default parameters. -
Scaffoldlets you build out a conventional material design screen by using individual composable functions. -
Use
ScaffoldStateto handle states for the components inside the scaffold. -
Use
DrawerStateto handle the drawer state, within aScaffold(). -
rememberScaffoldState()will remember the state of the scaffold and preserve it during the recomposition. -
rememberCoroutineScope()lets you create a composable-boundCoroutineScopeto launch coroutines and perform actions like closing or opening drawers.
Where to Go From Here?
You now know how to use multiple predefined composables to implement different features. You’ve also learned how to group and position them inside layouts to make a complete screen.
Next, you’ll learn about different ways of making lists, how to make adapters and how to get the same result as when you use a RecyclerView. Finally, you’ll learn how to implement custom grids.
See you in the next chapter!