Expanding the App

Refactor Into Separate Composables

In your project, open MainActivity.kt. Look through the code — where is the @Composable annotation?

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

It doesn’t seem to appear anywhere…

But now, take a closer look: MainActivity is a subclass of ComponentActivity. In the onCreate() function, you call setContent.

Command-click on setContent (or Control-click in Windows). Android Studio opens the definition of this function, which is defined in ComponentActivity.kt:

public fun ComponentActivity.setContent(
  parent: CompositionContext? = null,
  content: @Composable () -> Unit
) {...}

Aha! So, first of all, setContent is an extension function of ComponentActivity. Extension functions add more functionality to a class without changing its source code. Calling setContent() sets the given composable function named content as the root view, to which you can add any number of elements. You call the rest of your composable functions from within this container.

Secondly, note that content is also annotated with @Composable. Since the annotation is here, you don’t need to add it again before putting in composables like the Column above.

Right-click on com.kodeco.chat in the Project Navigator, and from the context menu, select New ▸ Package:

Name the new package “conversation”. This is where you’ll create the components that comprise the pieces of the chat UI.

Next, right-click on the conversation package, and select New ▸ Kotlin Class/File:

Ensure that “file” is selected from the various options, and name the new file “Conversation”:

Android Studio creates a new empty Kotlin file in the conversation package named Conversation.kt.

Inside Conversation.kt, type the following:

@Composable
fun ConversationContent() {
  // TODO: create conversation UI here
}

Congratulations on writing your first Compose function! It doesn’t do anything yet, but you’ll soon change that.

Go back to MainActivity.kt, copy everything from inside the braces setContent{}, and paste it into the body of ConversationContent:

@Composable
fun ConversationContent() {
 Column(
  modifier = Modifier
   .fillMaxSize()
   .padding(16.dp)
  ) {
    val context = LocalContext.current
    val chatInputText by remember { mutableStateOf(context.getString(R.string.chat_entry_default)) }
    val chatOutputText by remember { mutableStateOf(context.getString(R.string.chat_display_default)) }
        Text(
          text = chatOutputText,
          fontStyle = FontStyle.Italic, // 1
          color = Color.Magenta, // 2
          fontSize = 30.sp, // 3
          fontWeight = FontWeight.Bold, // 4
          modifier = Modifier
            .background(Color.Blue)
            .padding(16.dp)
        )

        OutlinedTextField(
          value = chatInputText,
          modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .background(Color.Green)
          ,

          onValueChange = {
          },
          label = { Text(text = stringResource(id = R.string.chat_entry_label)) }
        )

        Button(onClick = {},
          Modifier
            .width(200.dp)
            .align(alignment = Alignment.CenterHorizontally)
          ) {
          Text(text = stringResource(id = R.string.send_button))
  }
 }
}

Then, go back to MainActivity.kt, and replace everything in setContent{} with ConversationContent(). Your Activity class should now look much simpler and cleaner:

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

Build and run your app; it should work exactly as before.

While the simple copy/paste action you performed may seem trivial, it highlights a couple of key concepts. Firstly, note that Conversation.kt is a separate file, not a class. It simply houses a Compose function. This function could’ve been placed in MainActivity.kt, but as your codebase expands, maintaining a single file for all your code can become cumbersome. Secondly, you’ve now created a composable function named ConversationContent, which you can reuse throughout your app. This reusability is a cornerstone of Compose UI, akin to constructing a large sculpture from individual Lego blocks.

Some things to note about composable functions:

  • Composable functions can only be called from other composable functions.
  • Composable functions can receive parameters and use these parameters to build the UI.
  • Composable functions can only be invoked from a compose scope, similar to how coroutines work.

The UI you’ve created so far showcases a variety of Compose UI elements, each one a composable function capable of accepting various parameters:

  • Text: A basic text element that displays some text and provides some accessibility information.
  • OutlinedTextField: Unlike Text, which only displays text, a text field allows the user to type text into the UI. This variety of text field is supposed to have less visual emphasis with its particular style.
  • Button: This is just what it sounds like; users click buttons to initiate some sort of action.
  • Column: This is different from the other composables you’ve used — while Text, TextField, and Button are all UI elements, a Column is a type of layout composable. Layouts let you arrange UI elements in various ways. In the case of a Column, all of its children — the elements it contains — are laid out in a vertical or horizontal column. You’ll learn about several other layout composables, and you can even create custom layouts in Compose.

To jump into its class definition, simply Command-click (or Control-click in Windows) on each of these controls in your code. Each control is accompanied by a detailed comment above its code, the customizable parameters, and links to sample code.

Compose uses a declarative UI approach: You declare everything about how your UI should look using composable functions.

Take another look at the code for the Button in ConversationContent:

Button(onClick = {
	chatOutputText = chatInputText
	chatInputText = ""
}) {
	Text(text = stringResource(id = R.string.send_button))
}

The Button() composable has all the features you’ve learned about for Composable functions. It takes a parameter, onClick(), which is itself a function. In Kotlin, functions can take other functions, or lambdas, as parameters. The onClick() function, defined inline here, defines what action will occur when the button is clicked. The body of the Button() function contains a Text(), which is the button’s label. The Button() composable is a base button class that’s highly customizable, but there are five subclasses of Button() types you can use when you don’t need as much customization. For more information on the different button types and where and when to use them, see the official documentation for Button().

You might’ve noticed that composable functions use Pascal case, unlike the camel case commonly used in Kotlin code. Consequently, the top-level composable you defined is ConversationContent instead of conversationContent. This distinction stems from the fact that composable functions return UI objects, hence adopting the same naming convention as classes.

Layout Groups

Layout Groups in Compose allow you to arrange elements of your UI on the device screen in various ways. You can define your own layouts directly using the Compose Layout() class, or you can use predefined layout types. Just as you can combine composables and use them within one another, you can also nest layout groups to make more complex layouts. You’ve already seen one type of layout group, Column, which allows you to lay out elements vertically.

To arrange elements horizontally instead, you can use a Row.

Another layout composable is the Box. It’s used to display children (elements it contains) relative to their parent’s edges and allows you to stack or overlap children.

Finally, a Surface is a special layout that’s typically the top level, or root layout, in a series of nested composables. A Surface can only have one child at a time, but it provides many style treatments for its children. It’s used as the central metaphor for Material Design, Google’s standard design library that’s used in Android to provide a uniform user experience across devices.

Replace the body of ConverstationContent() with the following:

Surface {
  Box {
    Column {
      Messages()
      SimpleUserInput()
    }
  // Channel name bar floats above the messages
  ChannelNameBar(channelName = "Android Apprentice")
  }
}

Now, you’re using several of the layout composables you just learned about: Surface, Box, and Column. But you’ve also added some references to composables that don’t exist yet, which causes Android Studio to show you some errors.

Click one of the composables that appears in red, such as Messages. Android Studio shows the error message and offers a solution.

Or, you can click the red light bulb icon to the left of the composable to see the same options.

Select the “Create @Composable function…” for each of the undefined composables. Android Studio creates a stub function for that composable at the bottom of the file with a call to TODO() in the body of each. This is a special inline function that will cause a compile error if you try to run the app. This forces you, the developer, to implement the function before you can proceed with compiling the app. You’ll now see the following functions at the end of Conversation.kt:

@Composable
fun Messages() {
  TODO("Not yet implemented")
}

@Composable
fun SimpleUserInput() {
  TODO("Not yet implemented")
}

@Composable
fun ChannelNameBar(channelName: String) {

}

Note that there isn’t a TODO() for ChannelNameBar because you already partially implemented it by defining a parameter for the function.

Create another package, components, under com.kodeco.chat. Then, copy/paste KodecochatAppBar.kt and KodecochatIcon.kt from the final project for this lesson into your project. Next, copy/paste kodeco_logo.xml and kodeco_logo_back.xml from res ▸ drawable of the starter into the same location in your project. These last two files contain vector assets, which scale without pixelation and are rendered at runtime by Android. Also, copy the values from Strings.xml under res ▸ values so that you have ready access to all the localized strings used in this lesson.

Next, replace the body of ChannelNameBar() with the following:

KodecochatAppBar(
  title = {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
      // Channel name
      Text(
        text = channelName,
        style = MaterialTheme.typography.titleMedium
      )
    }
  },
  actions = {
    // Info icon
    Icon(
      imageVector = Icons.Outlined.Info,
      tint = MaterialTheme.colorScheme.onSurfaceVariant,
      modifier = Modifier
        .clickable(onClick = { })
        .padding(horizontal = 12.dp, vertical = 16.dp)
        .height(24.dp),
      contentDescription = stringResource(id = R.string.info)
    )
  }
)

KodecochatAppBar may appear with a red underline in Android Studio. If so, move the mouse over it, and you’ll see this dialogue in Android Studio:

Click “Opt in…”, and Android Studio will add an @OptIn(ExperimentalMaterial3Api::class) annotation above the function definition.

You’ll often see this OptIn followed by “Experimental” in Compose and other frameworks. While these frameworks are now mature, they’re continually evolving. So, to access some of the new features, sometimes you’ll need to “opt in” to using experimental features. Don’t worry — Android Studio will typically prompt you to add this annotation when it’s needed, and also provide you with a warning when the feature is no longer experimental and the annotation should be removed.

Replace any calls to TODO("Not yet implemented") with a regular comment like // TODO - Implement. This will allow you to build and run the app with compile issues, but the TODO comment gets highlighted with a blue tick (as opposed to red for errors and yellow for warnings) in the right gutter of the code pane in Android Studio:

Build and run. You’ll now see a new app bar at the top of the device screen:

Command-click (Control-click in Windows) on KodecochatAppBar to view the source:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KodecochatAppBar(
  modifier: Modifier = Modifier,
  scrollBehavior: TopAppBarScrollBehavior? = null,
  onNavIconPressed: () -> Unit = { },
  title: @Composable () -> Unit,
  actions: @Composable RowScope.() -> Unit = {}
) {
  // 1  
  CenterAlignedTopAppBar(
    modifier = modifier,
    // 2
    actions = actions,
    title = title,
    scrollBehavior = scrollBehavior,
    // 3    
    navigationIcon = {
      KodecoChatIcon(
        contentDescription = stringResource(id = R.string.navigation_drawer_open),
        modifier = Modifier
          .size(64.dp)
          .clickable(onClick = onNavIconPressed)
          .padding(16.dp)
      )
    }
  )
}
  1. You see that KodecochatAppBar is basically just a wrapper around the built-in class CenterAlignedTopAppBar. This is typically used to display information and actions at the top of a screen. The channelName parameter you passed into ChannelNameBar gets used for the title property in the CenterAlignedTopAppBar, which displays the title you now see at the top of the app screen. Furthermore, in Conversation.kt, the value passed to title isn’t just a string, it’s an entire composable function. This composable consists of a column, which has its alignment property set to center horizontally and its content set to a Text field, which captures the title string. In this way, you can see how Compose allows you to nest composables within one another to create more complex layouts and functionality.
  2. CenterAlignedTopAppBar, and therefore KodecochatAppBar, takes a parameter called actions. This is typically supposed to be a list of IconButtons. These are Material Design compact buttons that help the user take some supplementary action. In this case, you’re passing an info button, which looks like an “i” with a circle around it — the premise being that you could add code later to provide the user with some info about the chat channel when they tap it. Also note that the actions get rendered in a Row.
  3. CenterAlignedTopAppBar has another parameter, navigationIcon, that you’ll use to enable opening a side menu in your app later. For the icon, you’re using the Kodeco logo, which is provided as a vector asset. This, too, is built up as a composable. Notice again you’re passing an actual composable function to this parameter.

Previews

Open KodecochatAppBar.kt. At the top of Android Studio, beneath the run and debug icons, click the icon for split view, which is a combination of the code and design views:

You’ll see Android Studio split into a few different panes:

  1. In the middle is a preview of what the KodecochatAppBar will look like in both light and dark modes on the device.
  2. The code to generate these previews is written by you, the developer.
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun KodecochatAppBarPreview() {
  KodecochatTheme {
    KodecochatAppBar(title = { Text("Preview!") })
  }
}

To create a preview in composable, you just write a compose function and add the @Preview annotation above it in addition to the @Composable annotation. While it’s not required, the naming convention is to put “Preview” at the end of the function name as well for readability. You can then define the parameters your composable function requires, and Android Studio will automatically render the UI elements in the design view. Another cool feature of composable previews is that Android Studio will update them for you live as you edit your composable source code.

Try this now; back in KodecochatAppBar(), change the .padding() parameter passed in for KodecoChatIcon from 16 dp to 3 dp. As soon as you make the change, you’ll see the Kodeco logo on the left become much bigger. Change it back to the original value, and it shrinks back. You didn’t need to build or run the app to see these changes! This can make developing your app much faster, as you can often see design changes without having to run or rebuild the app. You can also use previews to see how a composable will look when rendered on different devices and different conditions, all at once. In this example, by defining two previews, you can see how the top bar looks in light and dark mode at the same time. For more information on using composable previews, see the Android documentation on Composable previews.

More With Modifiers

You learned in the last lesson that modifiers tell a UI element how to lay out, display, or behave within its parent layout, and you started using modifiers to style the app.

In the code in KodecochatAppBar.kt, you see modifier used repeatedly. In fact, most of the time, it’s an attribute on a composable function that’s then passed to a nested composable within it. This is a common practice you’ll see a lot in Compose, and not just with modifiers — sometimes you’ll add an attribute to a composable, not because you need to use it in that composable directly, but rather, just because you want to pass it on to another composable further down the line in the nesting hierarchy. This isn’t always the best practice, though — especially if you’re trying to pass data to the UI. Later, you’ll learn about ViewModels and how to use them to properly pass data unidirectionally to your UI.

Replace the body of SimpleUserInput() with this code, which is based on what you wrote earlier, but now it’s in a separate composable:

@Composable
fun SimpleUserInput() {
  val context = LocalContext.current
  var chatInputText by remember { mutableStateOf("") }
  var chatOutputText by remember { mutableStateOf(context.getString(R.string.chat_display_default)) }
  Text(text = chatOutputText)
  Row {
    OutlinedTextField(
      value = chatInputText,
      placeholder = { Text(text = stringResource(id = R.string.chat_entry_default)) },
      onValueChange = {
        chatInputText = it
      },
    )
    Button(onClick = {
      chatOutputText = chatInputText
      chatInputText = ""
    }) {
      Text(text = stringResource(id = R.string.send_button))
    }
  }
}

Build and run the app. Hmm, now this doesn’t look quite right…

It looks like the top app bar is overlapping the text field and button! You’ll use some modifiers to fix the layout. In ConversationContent(), add a two modifiers to the Box, fillMaxSize to expand the Box to fill the screen and background change the background color and make it easier to see the results:

Surface {
  Box(
    modifier = Modifier
      .fillMaxSize()
      .background(color = Color.DarkGray)
  ) {...

Build and run. Now, the difference is clear:

Great, the Box has definitely expanded to fill the entire screen, but the text entry portion of the UI is still covered. Update the contents of the Surface() as follows:

Surface {
  Box(modifier = Modifier.fillMaxSize()) {
    Column(
      Modifier
        .fillMaxSize()
     ) {
       Messages(
          modifier = Modifier.weight(1f),
        )
        SimpleUserInput()
      }
      // Channel name bar floats above the messages
      ChannelNameBar(channelName = "Android Apprentice")
    }
  }

Then, update the definition of Message() as follows:

@Composable
fun Messages(modifier: Modifier = Modifier){
  Box(modifier = modifier) {
    // TODO: implement this part in the next section!
  }
}

Build and run. Now, the layout looks much better!

You’ve already seen what fillMaxSize does, but what about weight? When Compose lays out the children of a composable, it measures them and then distributes and sizes them according to those measurements in the order the children are listed. The measurements are affected by what they contain and also by modifiers. The weight modifier takes a float value and sizes the element’s height according to the weight values of the other children in the Column — the parent container, in this case. The parent will divide the vertical space remaining after measuring unweighted child elements and distribute it according to this weight. In this case, since everything else in the column is unweighted, it gives most of the space over to the Box of Messages.

Lists

What happens when you have to display more elements than you can fit on the screen? In that case, while the elements are all composed, the limited screen size prevents you from seeing all of them. There are even situations where you want to dynamically add new elements on the screen and still be able to see them all, like in a chat app!

The solution to this problem is allowing your content to scroll, either vertically or horizontally. Jetpack Compose gives a way to build one of the most common UI components mobile apps use — using scrollable and lazily composed containers, aka the List.

Loading data only when it’s needed is called lazy loading, and Jetpack Compose uses this method to handle lists. The main two components you use for lazy lists in Compose are the LazyColumn and LazyRow.

Update Messages() as follows:

@Composable
fun Messages(
  messages: List<String>,
//  scrollState: LazyListState,
  modifier: Modifier = Modifier
) {
  Box(modifier = modifier) {
    LazyColumn(
      // Add content padding so that the content can be scrolled (y-axis)
      // below the status bar + app bar
      contentPadding =
      WindowInsets.statusBars.add(WindowInsets(top = 90.dp)).asPaddingValues(),
      modifier = Modifier
        .fillMaxSize()
    ) {
      item {
        Text(text = "First message")
      }
      item {
        Text(text = "Second message")
      }
      item {
        Text(text = "Third message")
      }
    }
  }
}

You’ve added a LazyColumn and hard coded a few dummy chat messages.

Build and run:

While the current approach of hard coding messages provides a basic foundation, a more dynamic solution is required to enhance the chat functionality. Ideally, messages entered into the text box should be seamlessly added to the existing list. Additionally, you need to know who sent the message and when it was sent. Furthermore, distinguishing your own messages from those of others through visual styling would significantly improve readability and user experience.

See forum comments
Download course materials from Github
Previous: Introduction Next: Demo