Chapters

Hide chapters

Android Fundamentals by Tutorials

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

6. Advanced Jetpack Compose
Written by Fuad Kamal

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the previous chapter, you learned about some building blocks in Compose UI to start developing a basic UI for an Android app. Using Compose, you built up the interface for the chat app using mocked data. It’s like you made the yummy-looking but completely fake cake some stores display. But you want to have your cake and eat it, too! In this chapter, you’ll learn how to make your app more functional using ViewModel for managing app data, adopt MVI (Model-View-Intent) to structure your app behavior and navigate through app screens using the Navigation library. Get ready to chat it up!

State

To make any app functional, you must know how to manage state. At its core, every app works with specific values that can change. For example, in Kodeco chat, a user can:

  • Add a new chat message
  • Delete a chat message
  • Upload an image attachment to a chat message

State is any value that can change over time. Those values can include anything from a database entry to a class property. As the state changes, it is crucial that the UI accurately reflects that state, so you’ll need to update UI when the state changes.

Compose is declarative, so the only way to update it is by calling the same composable with new arguments. These arguments are representations of the UI state. Any time a state is updated, a recomposition occurs. You might not have realized it, but you’ve already been updating the state of composables. Recall in UserInputText, the composable you used in the last chapter for the user to type in a chat message:

@ExperimentalFoundationApi
@Composable
private fun UserInputText(
  keyboardType: KeyboardType = KeyboardType.Text,
  onTextChanged: (TextFieldValue) -> Unit,
  textFieldValue: TextFieldValue,
  photoUri: Uri?,
  keyboardShown: Boolean,
  onTextFieldFocused: (Boolean) -> Unit,
  focusState: Boolean,
  onMessageSent: KeyboardActionScope.() -> Unit
) {
  // ...
  BasicTextField(
    value = textFieldValue,
    onValueChange = { onTextChanged(it) }
  )
  // ...

When you input text into a text field, it’s important for the displayed value to reflect your input in real-time. This is where you’ll use onValueChange. Every time you type a character into the text field, this composable gets recomposed. In other words, the state of the text field updates.

Also, recall you previously used remember to store the state of a composable:

var chatInputText by remember { mutableStateOf("") }

A composable that uses remember to store an object creates internal state, making the composable stateful. This can be useful when you have simple composables that you want to manage their own state. But these are also less reusable and harder to test.

State Hoisting

A stateless composable is a composable that doesn’t hold any state. An easy way to achieve stateless-ness is by using state hoisting. You’ve also been using this already. Again:

BasicTextField(
  value = textFieldValue,
  onValueChange = { onTextChanged(it) }
)

Unidirectional Data Flow

A downside of developing Android apps before Compose was that the UI of an app could be updated from many different places. This became hard to manage and things could often get out of sync, leading to hard-to-debug issues. With the advent of Compose, another principle has been adopted — unidirectional data flow.

Izozd Ytanu Cyane IO

ViewModel

In Chapter 3, “Android Fundamentals”, you learned about the lifecycle of Activities. In Android, Activity lifecycle events get triggered whenever a configuration change occurs, such as the device being rotated. Essentially, something benign like rotating the device causes activity to re-create from scratch. If you have data or state information tightly coupled to the UI, you might lose it when a configuration change occurs. As your app grows in complexity and scale, it becomes increasingly important to decouple your application data and logic outside the composables and the UI layer. Fortunately, Android provides a built-in architecture component to help you do that: the ViewModel.

class MainViewModel : ViewModel() {
}
// 1
private val userId = UUID.randomUUID().toString()
// 2
private val _messages: MutableList<MessageUiModel> = initialMessages.toMutableStateList()
// 3
private val _messagesFlow: MutableStateFlow<List<MessageUiModel>> by lazy {
  MutableStateFlow(emptyList())
}
val messages = _messagesFlow.asStateFlow()
// 1
fun onCreateNewMessageClick(messageText: String, photoUri: Uri?) {
  // 2
  val currentMoment: Instant = Clock.System.now()
  // 3
  val message = Message(
    UUID.randomUUID().toString(),
     currentMoment,
     currentRoom.value.id,
     messageText,
     userId,
     photoUri
  )
  // 4
  if (message.photoUri == null) {
    viewModelScope.launch(Dispatchers.Default) {
      createMessageForRoom(message, currentRoom.value)
    }
  }
}
// 5
suspend fun createMessageForRoom(message: Message, chatRoom: ChatRoom) {
  // 6
  val user = User(userId)
  val messageUIModel = MessageUiModel(message, user)
  // 7
  _messages.add(messageUIModel)
  // 8
  _messagesFlow.emit(_messages)
}

MVI, Flows and StateFlow

Traditionally, if your app needed data, it might create a request for this data via a network API or database service, etc. For example, when the view starts, you request data from the ViewModel, and then the ViewModel requests that data from a data layer. The received data returns in the other direction, from the data layer to the ViewModel, and then the UI gets updated. You might do all this asynchronously using suspend functions (coroutines).

Towu Zapul vveakuq femouzw Yeoc Fapaimnk Gixi Luek Jcilhp Suxe Padum foceijec zuke MaeyGupak Wikaeyil Hosu Real Wutaunut Numi

Tali Zehoj abvadyuy woujqo LianSadim Ewtezbem Lasa Jekim Toov Atricral KuugCekoj

val viewModel: MainViewModel
viewModel.onCreateNewMessageClick(msg, photoUri)
UserInput(onMessageSent = { content ->
  uiState.addMessage(content, null)
},
resetScroll = {
  scope.launch {
    scrollState.scrollToItem(0)
  }
},
// Use navigationBarsPadding() imePadding() and , to move the input panel above both the
modifier = Modifier
  .navigationBarsPadding()
  .imePadding(),
)
Messages(
  messages = uiState.messages,
  scrollState = scrollState,
  modifier = Modifier.weight(1f),
)
@Composable
fun Messages(
  messages: List<MessageUiModel>,
  scrollState: LazyListState,
  modifier: Modifier = Modifier,
) {
  Box(modifier = modifier) {
    LazyColumn(
      state = scrollState,
      // ...everything else is the same as before
class MainActivity : ComponentActivity() {
  // 1
  private val viewModel: MainViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      // 2
      val messagesWithUsers by viewModel.messages.collectAsStateWithLifecycle()
      // 3
      val currentUiState =
        ConversationUiState(
          channelName = "Android Apprentice",
          initialMessages = messagesWithUsers,
          viewModel = viewModel
        )

      KodecochatTheme {
        ConversationContent(
          currentUiState,
        )
      }
    }
  }
}
[versions]
lifecycle-runtime-compose = "2.6.2"

[libraries]
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle-runtime-compose" }
// compose lifecycle
    implementation(libs.androidx.lifecycle.runtime.compose)

MessageUi(
  onAuthorClick = {  },
  msg = content,
  authorId = "me",
  userId = userId ?: "",
  isFirstMessageByAuthor = isFirstMessageByAuthor,
  isLastMessageByAuthor = isLastMessageByAuthor,
)
var currentUserId = MutableStateFlow(userId)
val authorId: MutableStateFlow<String> = viewModel.currentUserId
val authorId = uiState.authorId.collectAsStateWithLifecycle()
Messages(
  messages = uiState.messages,
  authorId = authorId.value,
  scrollState = scrollState,
  modifier = Modifier.weight(1f),
)
@Composable
fun Messages(
  messages: List<MessageUiModel>,
  authorId: String,
  scrollState: LazyListState,
  modifier: Modifier = Modifier,
) {
authorId = content.user.id,
val isUserMe = authorId == userId
<string name="jumpBottom">Jump to bottom</string>
@Composable
fun Messages(
  messages: List<MessageUiModel>,
  authorId: String,
  scrollState: LazyListState,
  modifier: Modifier = Modifier,
) {
  // 1
  val scope = rememberCoroutineScope()
  Box(modifier = modifier) {
    LazyColumn(
      // 2
      reverseLayout = true,
      state = scrollState,
      // 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 = authorId,
          userId = userId ?: "",
          isFirstMessageByAuthor = isFirstMessageByAuthor,
          isLastMessageByAuthor = isLastMessageByAuthor,
        )
      }
    }
    // 3
    //
    val jumpThreshold = with(LocalDensity.current) {
      JumpToBottomThreshold.toPx()
    }
    // 4
    val jumpToBottomButtonEnabled by remember {
      derivedStateOf {
        scrollState.firstVisibleItemIndex != 0 ||
            scrollState.firstVisibleItemScrollOffset > jumpThreshold
      }
    }
    JumpToBottom(
      // 5
      enabled = jumpToBottomButtonEnabled,
      onClicked = {
        scope.launch {
          scrollState.animateScrollToItem(0)
        }
      },
      modifier = Modifier.align(Alignment.BottomCenter)
    )
  }
}
private val JumpToBottomThreshold = 56.dp
_messages.add(0, messageUIModel)

Key Points

Well done! You’ve covered some important concepts, architectures and design patterns in Android and Compose. To recap, you learned:

Where to Go From Here?

In this chapter, you’ve gotten a taste of some of the new architecture used with Jetpack Compose, namely MVI, and how to use Compose with other Jetpack libraries, such as ViewModel. To learn more about Google’s thoughts on architecture, their recommendations and learning paths, see the article Rebuilding our Guide to app Architecture and the actual guide to app architecture, which remains a work in progress but goes into much greater depth and broader scope than this chapter does.

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now