Chapters

Hide chapters

Android Fundamentals by Tutorials

First Edition · Android 14 · Kotlin 1.9 · Android Studio Hedgehog (2023.1.1)

5. Jetpack Compose
Written by Fuad Kamal

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Traditionally, Android applications relied on XML-based layouts. Android development has now shifted to Compose as the standard framework. Compose describes a much larger set of frameworks and application architecture in Kotlin, which isn’t specific to Android development. For example, the development teams at Slack have developed the Circuit framework, built on top of Compose. Compose UI is just one of the seven Compose frameworks specific to the UI layer of application development. Using Compose UI brings many improvements over the old View implementation, including significantly reduced build times, APK size and runtime performance. It also makes building the UI easier, more intuitive, and easier to maintain and debug. For a more in-depth comparison of Compose and the old Android View implementation, see this article.

Modern Android development has shifted from MVVM (Model-View-Viewmodel) to MVI (Model-View-Intent) architecture, with Compose UI now used for the UI layer rather than XML layouts. One of the key concepts behind MVI is unidirectional data flow. Often, you can have multiple data sources, including local storage and network sources. These are typically abstracted and accessed using a Repository pattern. A ViewModel accesses methods on the Repository and provides unidirectional data to the UI using Flow. A Flow typically consists of a data emitter and subscribers to the emitter. It provides an efficient memory usage model because the Flow won’t consume memory until and unless an active subscriber is using the data from the Flow. For Android, there are specific implementations of Flow that are Android lifecycle aware. Hence, when a view is no longer visible to the user, data is no longer consumed, freeing up memory.

In this chapter, you’ll learn the basics of Compose UI and build a simple Android app UI with it. In the next chapter, you’ll dive deeper and learn about the other essential pieces to wire a Compose app together, including the ViewModel, MVI, the Repository pattern and Navigation.

Compose Fundamentals

If you’ve been following along with the previous chapters, open your Kodeco Chat app in Android Studio. Otherwise, open this chapter’s starter project using Android Studio and select Open an existing project. Next, navigate to 05-jetpack-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync, and you’ll be ready to go!

Recall that in Chapter 2 you created a new Android project from scratch. The default Activity created for you contained the following function:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
  Text(
    text = "Hello $name!",
    modifier = modifier
  )
}

This code has two notable things about it. First, it’s a function; second, it’s annotated with @Composable. This is all you need to create a UI component in Compose, or, in Compose speak, a composable.

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 take a closer look: MainActivity is a subclass of ComponentActivity. In the onCreate() function you call setContent.

Command-click (if using a Mac; Control-click if using a PC) setContent. Android Studio opens this function’s definition, 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 additional 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.

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

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

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

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

Ensure that “file” is selected and name the new file “Conversation”:

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

In 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 and copy everything from inside the braces setContent{} and paste it into the body of ConversationContent:

@Composable
fun ConversationContent() {
  Column {
    val context = LocalContext.current
    var chatInputText by remember { mutableStateOf(context.getString(R.string.chat_entry_default)) }
    var chatOutputText by remember { mutableStateOf(context.getString(R.string.chat_display_default)) }
    Text(text = chatOutputText)

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

    Button(onClick = {
      chatOutputText = chatInputText
      chatInputText = ""
    }) {
      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 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.

Breaking Down Composables

Although the simple copy/paste action you performed might seem trivial, it highlights a couple key concepts. First, note that Conversation.kt is a separate file, not a class. It simply houses a Compose function. This function could have been placed in MainActivity.kt. But as your codebase expands, maintaining a single file for all your code can become cumbersome. Second, you’ve 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.

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

Improving the UI & UX

You might have noticed the inconvenience of having to manually delete the placeholder text when you start typing in the text field. This unnecessary step disrupts the user’s flow and introduces an additional mental hurdle, hindering their interaction with the app. Anything that detracts from the seamlessness and intuitiveness of the UI can lead to a poor user experience.

// 1
@OptIn(ExperimentalMaterial3Api::class)
// 2
@Composable
fun OutlinedTextField(
  value: String,
  onValueChange: (String) -> Unit,
  modifier: Modifier = Modifier,
  enabled: Boolean = true,
  readOnly: Boolean = false,
  textStyle: TextStyle = LocalTextStyle.current,
  // 3  
  label: @Composable (() -> Unit)? = null,
  // 4  
  placeholder: @Composable (() -> Unit)? = null,
  leadingIcon: @Composable (() -> Unit)? = null,
  trailingIcon: @Composable (() -> Unit)? = null,
  prefix: @Composable (() -> Unit)? = null,
  suffix: @Composable (() -> Unit)? = null,
  supportingText: @Composable (() -> Unit)? = null,
  isError: Boolean = false,
  visualTransformation: VisualTransformation = VisualTransformation.None,
  keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
  keyboardActions: KeyboardActions = KeyboardActions.Default,
  singleLine: Boolean = false,
  maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
  minLines: Int = 1,
  interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
  shape: Shape = OutlinedTextFieldDefaults.shape,
  colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
) {...

@Composable
fun ConversationContent() {
  Column {
    val context = LocalContext.current
    var chatInputText by remember { mutableStateOf("") }
    var chatOutputText by remember { mutableStateOf(context.getString(R.string.chat_display_default)) }
    Text(text = chatOutputText)

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

  }
}

Layout Groups

Layout Groups in Compose allow you to arrange your UI elements on the device screen in various ways. You can define layouts directly using the Compose Layout() class or use predefined layout types. Like 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.

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

Dealing With Compile Issues

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 special inline function will cause a compile error if you try to run the app, forcing you to implement the function before you can compile the app. You should 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) {

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

@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)
      )
    }
  )
}

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 in Android Studio:

@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun KodecochatAppBarPreview() {
  KodecochatTheme {
    KodecochatAppBar(title = { Text("Preview!") })
  }
}

Modifiers

Modifiers tell a UI element how to lay out, display or behave within its parent layout. You can also say they decorate or add behavior to UI elements.

@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))
    }
  }
}

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

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")
    }
  }
@Composable
fun Messages(modifier: Modifier = Modifier){
  Box(modifier = modifier) {
    // TODO: implement this part in the next section!
  }
}

Lists

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

@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")
      }
    }
  }
}

Building the Message UI

First, you should define what a Message actually is. Create a new package inside com.kodeco.chat , the data.model. Then, copy DateExtensions.kt, MessageUiModel.kt and User.kt from the Final project for this chapter from the same location to this one in your project.

// Date Time Library - the latest way to handle dates in Kotlin
  implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
class ConversationUiState(
  val channelName: String,
  initialMessages: List<MessageUiModel>,
) {
  private val _messages: MutableList<MessageUiModel> = initialMessages.toMutableStateList()

  val messages: List<MessageUiModel> = _messages

  fun addMessage(msg: String, photoUri: Uri?) {
    // TODO: implement in Chapter 6 😀
  }
}

@Immutable
data class Message(
  val _id: String = UUID.randomUUID().toString(),
  val createdOn: Instant? = Clock.System.now(),
  val roomId: String = "public", // "public" is the roomID for the default public chat room
  val text: String = "test",
  val userId: String = UUID.randomUUID().toString(),
  val photoUri: Uri? = null,
  val authorImage: Int = if (userId == "me") R.drawable.profile_photo_android_developer else R.drawable.someone_else
)
Messages(
  messages = uiState.messages,
  modifier = Modifier.weight(1f)
)
@Composable
fun Messages(
  messages: List<MessageUiModel>,
  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()
    ) {
      itemsIndexed(
        items = messages,
        key= { _, message -> message.id }
      ) { index, content ->
        val prevAuthor = messages.getOrNull(index - 1)?.message?.userId
        val nextAuthor = messages.getOrNull(index + 1)?.message?.userId
        val userId = messages.getOrNull(index)?.message?.userId
        val isFirstMessageByAuthor = prevAuthor != content.message.userId
        val isLastMessageByAuthor = nextAuthor != content.message.userId
        MessageUi(
          onAuthorClick = {  },
          msg = content,
          authorId = "me",
          userId = userId ?: "",
          isFirstMessageByAuthor = isFirstMessageByAuthor,
          isLastMessageByAuthor = isLastMessageByAuthor,
        )
      }
    }
  }
}
@Composable
fun MessageUi(
  onAuthorClick: (String) -> Unit,
  msg: MessageUiModel,
  authorId: String,
  userId: String,
  isFirstMessageByAuthor: Boolean,
  isLastMessageByAuthor: Boolean,
) {
  val isUserMe = userId == "me" // hard coded for now, next chapter will be = authorId == userId
  val borderColor = if (isUserMe) {
    MaterialTheme.colorScheme.primary
  } else {
    MaterialTheme.colorScheme.tertiary
  }

  val authorImageId: Int = if (isUserMe) R.drawable.profile_photo_android_developer else R.drawable.someone_else
  val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
  Row(modifier = spaceBetweenAuthors) {
    if (isLastMessageByAuthor) {
      // Avatar
      Image(
        modifier = Modifier
          .clickable(onClick = { onAuthorClick(msg.message.userId) })
          .padding(horizontal = 16.dp)
          .size(42.dp)
          .border(1.5.dp, borderColor, CircleShape)
          .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
          .clip(CircleShape)
          .align(Alignment.Top),
        painter = painterResource(id = authorImageId),
        contentScale = ContentScale.Crop,
        contentDescription = null
      )
    } else {
      // Space under avatar
      Spacer(modifier = Modifier.width(74.dp))
    }
    AuthorAndTextMessage(
      msg = msg,
      isUserMe = isUserMe,
      isFirstMessageByAuthor = isFirstMessageByAuthor,
      isLastMessageByAuthor = isLastMessageByAuthor,
      authorClicked = onAuthorClick,
      modifier = Modifier
        .padding(end = 16.dp)
        .weight(1f)
    )
  }
}

@Composable
fun AuthorAndTextMessage(
  msg: MessageUiModel,
  isUserMe: Boolean,
  isFirstMessageByAuthor: Boolean,
  isLastMessageByAuthor: Boolean,
  authorClicked: (String) -> Unit,
  modifier: Modifier = Modifier
) {
  Column(modifier = modifier) {
    if (isLastMessageByAuthor) {
      AuthorNameTimestamp(msg, isUserMe)
    }
    ChatItemBubble(
      msg.message,
      isUserMe,
      authorClicked = authorClicked)
    if (isFirstMessageByAuthor) {
      // Last bubble before next author
      Spacer(modifier = Modifier.height(8.dp))
    } else {
      // Between bubbles
      Spacer(modifier = Modifier.height(4.dp))
    }
  }
}

@Composable
private fun AuthorNameTimestamp(msg: MessageUiModel, isUserMe: Boolean = false) {
  var userFullName: String = msg.user.fullName
  if (isUserMe) {
    userFullName = "me"
  }

  // Combine author and timestamp for author.
  Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
    Text(
      text = userFullName,
      style = MaterialTheme.typography.titleMedium,
      modifier = Modifier
        .alignBy(LastBaseline)
        .paddingFrom(LastBaseline, after = 8.dp) // Space to 1st bubble
    )
    Spacer(modifier = Modifier.width(8.dp))
    Text(
      text = msg.message.createdOn.toString().isoToTimeAgo(),
      style = MaterialTheme.typography.bodySmall,
      modifier = Modifier.alignBy(LastBaseline),
      color = MaterialTheme.colorScheme.onSurfaceVariant
    )
  }
}

@Composable
fun ChatItemBubble(
  message: Message,
  isUserMe: Boolean,
  authorClicked: (String) -> Unit
) {
  val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
  val pressedState = remember { mutableStateOf(false) }
  val backgroundBubbleColor = if (isUserMe) {
    MaterialTheme.colorScheme.primary
  } else {
    MaterialTheme.colorScheme.surfaceVariant
  }
  Column {
    Surface(
      color = backgroundBubbleColor,
      shape = ChatBubbleShape
    ) {
      if (message.text.isNotEmpty()) {
        ClickableMessage(
          message = message,
          isUserMe = isUserMe,
          authorClicked = authorClicked
        )
      }
    }
  }
}

@Composable
fun ClickableMessage(
  message: Message,
  isUserMe: Boolean,
  authorClicked: (String) -> Unit
) {
  val uriHandler = LocalUriHandler.current
  val styledMessage = messageFormatter(
    text = message.text,
    primary = isUserMe
  )

  ClickableText(
    text = styledMessage,
    style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
    modifier = Modifier.padding(16.dp),
    onClick = {
      styledMessage
        .getStringAnnotations(start = it, end = it)
        .firstOrNull()
        ?.let { annotation ->
          when (annotation.tag) {
            SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
            SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
            else -> Unit
          }
        }
    }
  )
}
setContent {
  ConversationContent(
    uiState = exampleUiState
  )
}

Themes & Working With Fonts

In Compose, you can use the downloadable fonts API in your Compose app to download Google fonts asynchronously and use them in your app. For step-by-step details on using Google fonts, see the official documentation. You can define a theme in your app and then use your custom fonts, including Google fonts. Once you’ve done that, wrapping your UI in the theme in the setContent is a simple step. To demonstrate, add the dependency for Google fonts to your Module level gradle file:

setContent {
  KodecochatTheme {
     ConversationContent(
       uiState = exampleUiState
     )
  }
}

Updating the User Input Field

One last touch and you’re done for now! In Conversation.kt, delete the call to SimpleUserInput in ConversationContent and then delete that composable’s definition. Replace it with this:

Key Points

Well done! Congratulations on getting this far! This was a packed chapter, and you had a whirlwind adventure with Compose UI! In this chapter, you learned:

Where to Go From Here?

To learn more about Jetpack Compose fundamentals, see the official Android documentation at https://developer.android.com/jetpack/compose/documentation. In the next chapter, you’ll dive even deeper into Compose UI, leveraging some very powerful design patterns that will take your code to the next level. You’ll also continue to develop the Kodeco Chat app!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now