Skip to content

Commit 5f2f2cf

Browse files
authored
Migrate Jetchat from Fragment Navigation to Navigation 3 (#1732)
Migrates Jetchat's navigation from Fragments and XML `NavHostFragment` to `Nav3`. ### Before - `NavActivity` (`AppCompatActivity`) hosted an XML `NavHostFragment` (`content_main.xml` + `mobile_navigation.xml`) via `AndroidViewBinding`. - `ConversationFragment` and `ProfileFragment` wrapped Compose screens inside `ComposeView`s and communicated drawer events through `MainViewModel`. ### After - `NavActivity` (`ComponentActivity`) hosts `ConversationContent` and `ProfileScreen` directly in a Navigation 3 `NavDisplay` backed by `rememberNavBackStack(Conversation)`. - Destinations are defined as type-safe `@Serializable` `NavKey`s (`Conversation` and `Profile(userId)`), with `ViewModel`s scoped per entry via `rememberViewModelStoreNavEntryDecorator()`. - Removes `ConversationFragment`, `ProfileFragment`, `MainViewModel`, XML navigation/layout resources, and legacy Fragment/`viewBinding` dependencies.
2 parents 25e82d9 + 2ad3c76 commit 5f2f2cf

18 files changed

Lines changed: 249 additions & 427 deletions

File tree

‎Jetchat/README.md‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ project from Android Studio following the steps
1313
This sample showcases:
1414

1515
* UI state management
16-
* Integration with Architecture Components: Navigation, Fragments, ViewModel
16+
* Integration with Architecture Components: Navigation 3, ViewModel
1717
* Back button handling
1818
* Text Input and focus management
1919
* Multiple types of animations and transitions
@@ -39,8 +39,8 @@ Jetchat is still in under development, and some features are not yet implemented
3939
The [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) composable is the entry point to this screen and takes a [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt) that defines the data to be displayed. This doesn't mean all the state is served from a single point: composables can have their own state too. For an example, see `scrollState` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) or `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt)
4040

4141
### Architecture Components
42-
The [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt) shows how to pass data between fragments with the [Navigation component](https://developer.android.com/guide/navigation) and observe state from a
43-
[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata).
42+
[JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt) hosts the screens in a [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) `NavDisplay`. The destinations are the typed keys defined in [JetchatNavKeys](app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt): opening a profile pushes a `Profile(userId)` key onto the back stack, which is created with `rememberNavBackStack` so that it survives configuration changes and process death. The back stack is the single source of truth for navigation: the drawer derives its selected item from the key on top of it. The profile entry passes the `userId` of its key to a
43+
[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) that is scoped to that entry with `rememberViewModelStoreNavEntryDecorator`, and observes its state served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata).
4444

4545
### Back button handling
4646
When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. The implementation can be found in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt).
@@ -57,22 +57,19 @@ This sample is laid out [edge-to-edge](https://medium.com/androiddevelopers/gest
5757
The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsPadding().imePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt).
5858

5959
### Saved state across configuration changes
60-
Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt).
60+
Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). The navigation back stack is saved too, see `rememberNavBackStack` in [JetchatApp](app/src/main/java/com/example/compose/jetchat/JetchatApp.kt).
6161

6262
### Material Design 3 theming and Material You dynamic color
6363
Jetchat follows the [Material Design 3](https://m3.material.io) principles and uses the `MaterialTheme` composable and M3 components. On Android 12+ Jetchat supports Material You dynamic color, which extracts a custom color scheme from the device wallpaper. Jetchat uses a custom, branded color scheme as a fallback. It also implements custom typography using the Karla and Montserrat font families.
6464

65-
### Nested scrolling interop
66-
Jetchat contains an example of how to use [`rememberNestedScrollInteropConnection()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#rememberNestedScrollInteropConnection()) to achieve successful nested scroll interop between a View parent that implements `androidx.core.view.NestedScrollingParent3` and a Compose child. The example used here is a combination of a View parent `CoordinatorLayout` and a nested, Compose child `BoxWithConstraints` in [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt).
67-
6865
### UI tests
6966
In [androidTest](app/src/androidTest/java/com/example/compose/jetchat) you'll find a suite of UI tests that showcase interesting patterns in Compose:
7067

7168
#### [ConversationTest](app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt)
7269
UI tests for the Conversation screen. Includes a test that checks the behavior of the app when dark mode changes.
7370

7471
#### [NavigationTest](app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt)
75-
Shows how to write tests that assert directly on the [Navigation Controller](https://developer.android.com/reference/androidx/navigation/NavController).
72+
Checks the navigation flows of the app (opening profiles from the drawer, going back, popping the back stack) by asserting on what is displayed.
7673

7774
#### [UserInputTest](app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt)
7875
Checks that the user input composable, including extended controls, behave as expected showing and hiding the keyboard.

‎Jetchat/app/build.gradle.kts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2020
plugins {
2121
alias(libs.plugins.android.application)
2222
alias(libs.plugins.compose)
23+
alias(libs.plugins.kotlin.serialization)
2324
}
2425

2526
android {
@@ -77,7 +78,6 @@ android {
7778

7879
buildFeatures {
7980
compose = true
80-
viewBinding = true
8181
}
8282

8383
packaging.resources {
@@ -105,15 +105,15 @@ dependencies {
105105
implementation(libs.androidx.compose.runtime.livedata)
106106
implementation(libs.androidx.lifecycle.viewModelCompose)
107107
implementation(libs.androidx.lifecycle.runtime.compose)
108-
implementation(libs.androidx.navigation.fragment)
109-
implementation(libs.androidx.navigation.ui.ktx)
108+
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
109+
implementation(libs.androidx.navigation3.runtime)
110+
implementation(libs.androidx.navigation3.ui)
110111

111112
implementation(libs.androidx.compose.foundation.layout)
112113
implementation(libs.androidx.compose.material3)
113114
implementation(libs.androidx.compose.ui.tooling.preview)
114115
debugImplementation(libs.androidx.compose.ui.tooling)
115116
implementation(libs.androidx.compose.ui.util)
116-
implementation(libs.androidx.compose.ui.viewbinding)
117117
implementation(libs.androidx.compose.ui.googlefonts)
118118

119119
implementation(libs.androidx.media3.exoplayer)

‎Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt‎

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,12 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
2626
import androidx.compose.ui.test.onFirst
2727
import androidx.compose.ui.test.onNodeWithContentDescription
2828
import androidx.compose.ui.test.onNodeWithText
29-
import androidx.compose.ui.test.onRoot
3029
import androidx.compose.ui.test.performClick
3130
import androidx.compose.ui.test.performScrollTo
3231
import androidx.compose.ui.test.performTouchInput
3332
import androidx.compose.ui.test.swipeRight
34-
import androidx.navigation.NavController
35-
import androidx.navigation.findNavController
3633
import androidx.test.espresso.Espresso
37-
import org.junit.Assert.assertEquals
34+
import org.junit.Assert.assertTrue
3835
import org.junit.Rule
3936
import org.junit.Test
4037

@@ -49,17 +46,14 @@ class NavigationTest {
4946
@Test
5047
fun app_launches() {
5148
// Check app launches at the correct destination
52-
assertEquals(getNavController().currentDestination?.id, R.id.nav_home)
49+
assertConversationIsDisplayed()
5350
}
5451

5552
@Test
5653
fun profileScreen_back_conversationScreen() {
57-
val navController = getNavController()
5854
// Navigate to profile
5955
navigateToProfile("Taylor Brooks")
60-
// Check profile is displayed
61-
assertEquals(navController.currentDestination?.id, R.id.nav_profile)
62-
// Extra UI check (scroll to Display name as the new profile header is taller)
56+
// Check profile is displayed (scroll to Display name as the new profile header is taller)
6357
composeTestRule
6458
.onNodeWithText(composeTestRule.activity.getString(R.string.display_name))
6559
.performScrollTo()
@@ -69,7 +63,7 @@ class NavigationTest {
6963
Espresso.pressBack()
7064

7165
// Check that we're home
72-
assertEquals(navController.currentDestination?.id, R.id.nav_home)
66+
assertConversationIsDisplayed()
7367
}
7468

7569
/**
@@ -83,7 +77,19 @@ class NavigationTest {
8377
navigateToHomeFromProfile()
8478

8579
// Chewie, we're home
86-
assertEquals(getNavController().currentDestination?.id, R.id.nav_home)
80+
assertConversationIsDisplayed()
81+
82+
// The profiles were popped off the back stack, so pressing back leaves the app
83+
val activity = composeTestRule.activity
84+
Espresso.pressBackUnconditionally()
85+
composeTestRule.runOnIdle {
86+
assertTrue(activity.isFinishing)
87+
}
88+
}
89+
90+
private fun assertConversationIsDisplayed() {
91+
// The channel name is shown in the conversation's app bar (and in the drawer, which is excluded)
92+
composeTestRule.onNode(hasText("composers") and !isInDrawer()).assertIsDisplayed()
8793
}
8894

8995
private fun navigateToProfile(name: String) {
@@ -101,22 +107,10 @@ class NavigationTest {
101107
composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu),
102108
)
103109

104-
private fun navigateToHome() {
105-
composeTestRule.onNodeWithContentDescription(
106-
composeTestRule.activity.getString(R.string.navigation_drawer_open),
107-
).performClick()
108-
109-
composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick()
110-
}
111-
112110
private fun navigateToHomeFromProfile() {
113111
composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput {
114112
swipeRight()
115113
}
116114
composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick()
117115
}
118-
119-
private fun getNavController(): NavController {
120-
return composeTestRule.activity.findNavController(R.id.nav_host_fragment)
121-
}
122116
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.compose.jetchat
18+
19+
import androidx.compose.material3.DrawerValue.Closed
20+
import androidx.compose.material3.rememberDrawerState
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.livedata.observeAsState
24+
import androidx.compose.runtime.rememberCoroutineScope
25+
import androidx.lifecycle.viewmodel.compose.viewModel
26+
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
27+
import androidx.navigation3.runtime.entryProvider
28+
import androidx.navigation3.runtime.rememberNavBackStack
29+
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
30+
import androidx.navigation3.ui.NavDisplay
31+
import com.example.compose.jetchat.components.JetchatDrawer
32+
import com.example.compose.jetchat.conversation.ConversationContent
33+
import com.example.compose.jetchat.conversation.ConversationViewModel
34+
import com.example.compose.jetchat.navigation.Conversation
35+
import com.example.compose.jetchat.navigation.Profile
36+
import com.example.compose.jetchat.navigation.openConversation
37+
import com.example.compose.jetchat.navigation.openProfile
38+
import com.example.compose.jetchat.profile.ProfileError
39+
import com.example.compose.jetchat.profile.ProfileScreen
40+
import com.example.compose.jetchat.profile.ProfileViewModel
41+
import kotlinx.coroutines.launch
42+
43+
/**
44+
* Root composable of the app: the navigation drawer around a Navigation 3 [NavDisplay].
45+
*
46+
* The back stack is the single source of truth for navigation. The drawer derives its selection
47+
* from it and sends navigation events back to it, following unidirectional data flow.
48+
*/
49+
@Composable
50+
fun JetchatApp() {
51+
// The back stack survives configuration changes and process death
52+
val backStack = rememberNavBackStack(Conversation("composers"))
53+
val drawerState = rememberDrawerState(initialValue = Closed)
54+
val scope = rememberCoroutineScope()
55+
56+
JetchatDrawer(
57+
// Derived from the back stack, so it can't get out of sync with the displayed screen
58+
selectedKey = backStack.last(),
59+
onNavigate = { key ->
60+
when (key) {
61+
is Conversation -> backStack.openConversation(key.channel)
62+
is Profile -> backStack.openProfile(key.userId)
63+
}
64+
scope.launch {
65+
drawerState.close()
66+
}
67+
},
68+
drawerState = drawerState,
69+
) {
70+
NavDisplay(
71+
backStack = backStack,
72+
onBack = { backStack.removeLastOrNull() },
73+
// Scope the ViewModels to their entry. The ViewModelStoreNavEntryDecorator
74+
// requires the default SaveableStateHolderNavEntryDecorator, which saves the
75+
// state of each entry.
76+
entryDecorators = listOf(
77+
rememberSaveableStateHolderNavEntryDecorator(),
78+
rememberViewModelStoreNavEntryDecorator(),
79+
),
80+
entryProvider = entryProvider {
81+
entry<Conversation> { key ->
82+
// The channel comes from the key, so pass it when the ViewModel of this
83+
// entry is created
84+
val viewModel = viewModel { ConversationViewModel(channel = key.channel) }
85+
ConversationContent(
86+
uiState = viewModel.uiState,
87+
navigateToProfile = { userId -> backStack.openProfile(userId) },
88+
onNavIconPressed = {
89+
scope.launch {
90+
drawerState.open()
91+
}
92+
},
93+
onMessageLikeToggled = viewModel::toggleLike,
94+
)
95+
}
96+
entry<Profile> { key ->
97+
// The user id comes from the key, so set it when the ViewModel of this
98+
// entry is created
99+
val viewModel = viewModel {
100+
ProfileViewModel().apply { setUserId(key.userId) }
101+
}
102+
val userData by viewModel.userData.observeAsState()
103+
104+
if (userData == null) {
105+
ProfileError()
106+
} else {
107+
ProfileScreen(
108+
userData = userData!!,
109+
)
110+
}
111+
}
112+
},
113+
)
114+
}
115+
}

‎Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt‎

Lines changed: 0 additions & 38 deletions
This file was deleted.

0 commit comments

Comments
 (0)