Skip to content

Commit 120496e

Browse files
authored
Migrate Jetnews to Navigation 3 (#1675)
## Key Changes * Created `NavKey` objects and classes to replace the `JetnewsDestinations` object. * Refactored the home route to only be responsible for displaying the list of posts and moved the post detail UI and logic into its own route. * Implemented a `ListDetailSceneStrategy` to display the post detail view alongside the list of posts if the window width size class is at least expanded. * Added `NavigationState` and `Navigator` classes and helpers for storing and modifying the app's navigation state. * Replaced `JetnewsNavGraph` with `JetnewsNavDisplay`. * Removed Navigation 2 dependencies * Implemented support for deeplinks for the home page, post detail page, and interest page. * Improved the `app_opensArticle` (now `app_opensPost`) test so that it now actually tests that the post content is displayed.
2 parents 1bf1c20 + c4eed0b commit 120496e

36 files changed

Lines changed: 1599 additions & 815 deletions

JetNews/README.md

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,28 @@ project from Android Studio following the steps
1515

1616
## Features
1717

18-
This sample contains three screens: a list of articles, a detail page for articles, and a page to
19-
subscribe to topics of interest. The navigation from the the list of articles to the interests
18+
This sample contains three screens: a list of posts, a detail page for a post, and a page to
19+
subscribe to topics of interest. The navigation from the list of posts to the interests
2020
screen uses a navigation drawer.
2121

2222
### App scaffolding
2323

2424
Package [`com.example.jetnews.ui`][1]
2525

26-
[`JetnewsApp.kt`][2] arranges the different screens in the `NavDrawerLayout`.
26+
[`JetnewsApp.kt`][2] sets up the app's navigation state and the modal drawer used for navigation
27+
on smaller windows.
2728

28-
[`JetnewsNavGraph.kt`][3] configures the navigation routes and actions in the app.
29+
[`JetnewsNavDisplay.kt`][3] displays the primary content of the app: the list of posts, the
30+
posts themselves, and the interests page. It uses a list-detail scene strategy to adaptively
31+
display more or less content depending on the window size.
32+
33+
<img src="screenshots/jetnews_all_screens.png" alt="Screenshot">
2934

3035
[1]: app/src/main/java/com/example/jetnews/ui
3136
[2]: app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt
32-
[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt
37+
[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt
3338

34-
### Main article list
39+
### Main post list
3540

3641
Package [`com.example.jetnews.ui.home`][4]
3742

@@ -47,14 +52,14 @@ See how to:
4752

4853
[4]: app/src/main/java/com/example/jetnews/ui/home
4954

50-
### Article detail
55+
### Post detail
5156

52-
Package [`com.example.jetnews.ui.article`][5]
57+
Package [`com.example.jetnews.ui.post`][5]
5358

5459
This screen dives into the Text API, showing how to use different fonts than the ones defined in
5560
[`Typography`][6]. It also adds a bottom app bar, with custom actions.
5661

57-
[5]: app/src/main/java/com/example/jetnews/ui/article
62+
[5]: app/src/main/java/com/example/jetnews/ui/post
5863
[6]: app/src/main/java/com/example/jetnews/ui/theme/Type.kt
5964

6065
### Interests screen
@@ -99,20 +104,6 @@ UI tests can be run on device/emulators or on JVM with Robolectric.
99104
* To run Instrumented tests use the "Instrumented tests" run configuration or run the `./gradlew connectedCheck` command.
100105
* To run tests with Robolectric use the "Robolectric tests" run configuration or run the `./gradlew testDebug` command.
101106

102-
## Jetnews for every screen
103-
104-
<img src="screenshots/jetnews_all_screens.png" alt="Screenshot">
105-
106-
We recently updated Jetnews to enhance its behavior across all mobile devices, both big and small.
107-
Jetnews already had support for “traditional” mobile screens, so it was tempting to describe all of
108-
our changes as “adding large screen support.” While that is true, it misses the point of having
109-
adaptive UI. For example, if your app is running in split screen mode on a tablet, it shouldn't try
110-
to display “tablet UI” unless it actually has enough space for it. With all of these changes,
111-
Jetnews is working better than ever on large screens, but also on small screens too.
112-
113-
Check out the blog post that explains all the changes in more details:
114-
https://medium.com/androiddevelopers/jetnews-for-every-screen-4d8e7927752
115-
116107
## License
117108

118109
```

JetNews/app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
1919

2020
plugins {
2121
alias(libs.plugins.android.application)
22+
alias(libs.plugins.kotlin.serialization)
2223
alias(libs.plugins.compose)
2324
}
2425

@@ -92,10 +93,12 @@ dependencies {
9293

9394
implementation(libs.kotlin.stdlib)
9495
implementation(libs.kotlinx.coroutines.android)
96+
implementation(libs.kotlinx.serialization.core)
9597

9698
implementation(libs.androidx.compose.animation)
9799
implementation(libs.androidx.compose.foundation.layout)
98100
implementation(libs.androidx.compose.material3)
101+
implementation(libs.androidx.compose.material3.adaptive)
99102
implementation(libs.androidx.compose.materialWindow)
100103
implementation(libs.androidx.compose.runtime.livedata)
101104
implementation(libs.androidx.compose.ui.tooling.preview)
@@ -116,7 +119,10 @@ dependencies {
116119
implementation(libs.androidx.lifecycle.livedata.ktx)
117120
implementation(libs.androidx.lifecycle.viewModelCompose)
118121
implementation(libs.androidx.lifecycle.runtime.compose)
122+
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
119123
implementation(libs.androidx.navigation.compose)
124+
implementation(libs.androidx.navigation3.runtime)
125+
implementation(libs.androidx.navigation3.ui)
120126
implementation(libs.androidx.window)
121127

122128
androidTestImplementation(libs.junit)

JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@ import androidx.compose.ui.test.hasText
2222
import androidx.compose.ui.test.junit4.createAndroidComposeRule
2323
import androidx.compose.ui.test.onNodeWithContentDescription
2424
import androidx.compose.ui.test.onNodeWithText
25-
import androidx.compose.ui.test.onRoot
2625
import androidx.compose.ui.test.performClick
27-
import androidx.compose.ui.test.printToString
26+
import androidx.compose.ui.test.performScrollTo
2827
import androidx.test.core.app.ApplicationProvider
2928
import androidx.test.ext.junit.runners.AndroidJUnit4
3029
import com.example.jetnews.data.posts.impl.manuel
@@ -54,18 +53,11 @@ class JetnewsTests {
5453
}
5554

5655
@Test
57-
fun app_opensArticle() {
58-
59-
println(composeTestRule.onRoot().printToString())
60-
composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0].performClick()
61-
62-
println(composeTestRule.onRoot().printToString())
63-
try {
64-
composeTestRule.onAllNodes(hasText("3 min read", substring = true))[0].assertExists()
65-
} catch (e: AssertionError) {
66-
println(composeTestRule.onRoot().printToString())
67-
throw e
68-
}
56+
fun app_opensPost() {
57+
composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0]
58+
.performScrollTo()
59+
.performClick()
60+
composeTestRule.waitUntilExactlyOneExists(hasText("Use Dagger in Kotlin!", substring = true), 5000L)
6961
}
7062

7163
@Test

JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
package com.example.jetnews
1818

1919
import android.content.Context
20-
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
2120
import androidx.compose.ui.test.junit4.ComposeContentTestRule
2221
import com.example.jetnews.ui.JetnewsApp
22+
import com.example.jetnews.ui.home.HomeKey
2323

2424
/**
2525
* Launches the app from a test context
@@ -28,7 +28,8 @@ fun ComposeContentTestRule.launchJetNewsApp(context: Context) {
2828
setContent {
2929
JetnewsApp(
3030
appContainer = TestAppContainer(context),
31-
widthSizeClass = WindowWidthSizeClass.Compact,
31+
isBackEnabled = true,
32+
initialBackStack = listOf(HomeKey),
3233
)
3334
}
3435
}

JetNews/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<activity
2828
android:name=".ui.MainActivity"
2929
android:exported="true"
30+
android:launchMode="singleTop"
3031
android:windowSoftInputMode="adjustResize">
3132
<intent-filter>
3233
<action android:name="android.intent.action.MAIN" />
@@ -35,6 +36,7 @@
3536
<intent-filter>
3637
<action android:name="android.intent.action.VIEW" />
3738
<category android:name="android.intent.category.BROWSABLE"/>
39+
<category android:name="android.intent.category.DEFAULT" />
3840
<data
3941
android:host="developer.android.com"
4042
android:pathPrefix="/jetnews"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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.jetnews.deeplink
18+
19+
import android.net.Uri
20+
import androidx.navigation3.runtime.NavKey
21+
import com.example.jetnews.deeplink.util.DeepLinkMatcher
22+
import com.example.jetnews.deeplink.util.DeepLinkRequest
23+
import com.example.jetnews.deeplink.util.KeyDecoder
24+
import com.example.jetnews.ui.home.HomeDeepLinkPattern
25+
import com.example.jetnews.ui.interests.InterestsDeepLinkPattern
26+
import com.example.jetnews.ui.navigation.DeepLinkKey
27+
import com.example.jetnews.ui.post.PostDeepLinkPattern
28+
29+
val JetnewsDeepLinkPatterns = listOf(HomeDeepLinkPattern, PostDeepLinkPattern, InterestsDeepLinkPattern)
30+
31+
fun Uri.handleDeepLink(): List<NavKey>? {
32+
val deepLinkRequest = DeepLinkRequest(this)
33+
34+
val deepLinkMatchResult = JetnewsDeepLinkPatterns.firstNotNullOfOrNull {
35+
DeepLinkMatcher(deepLinkRequest, it).match()
36+
} ?: return null
37+
38+
val initialKey = KeyDecoder(deepLinkMatchResult.args).decodeSerializableValue(deepLinkMatchResult.serializer)
39+
40+
return generateSequence(initialKey) { (it as? DeepLinkKey)?.parent }
41+
.toList()
42+
.asReversed()
43+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.jetnews.deeplink.util
18+
19+
import android.util.Log
20+
import androidx.navigation3.runtime.NavKey
21+
import kotlinx.serialization.KSerializer
22+
23+
internal class DeepLinkMatcher<T : NavKey>(val request: DeepLinkRequest, val deepLinkPattern: DeepLinkPattern<T>) {
24+
/**
25+
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
26+
*
27+
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
28+
*/
29+
fun match(): DeepLinkMatchResult<T>? {
30+
if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null
31+
if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null
32+
if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
33+
// exact match (url does not contain any arguments)
34+
if (request.uri == deepLinkPattern.uriPattern)
35+
return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())
36+
37+
val args = mutableMapOf<String, Any>()
38+
// match the path
39+
request.pathSegments
40+
.asSequence()
41+
// zip to compare the two objects side by side, order matters here so we
42+
// need to make sure the compared segments are at the same position within the url
43+
.zip(deepLinkPattern.pathSegments.asSequence())
44+
.forEach { it ->
45+
// retrieve the two path segments to compare
46+
val requestedSegment = it.first
47+
val candidateSegment = it.second
48+
// if the potential match expects a path arg for this segment, try to parse the
49+
// requested segment into the expected type
50+
if (candidateSegment.isParamArg) {
51+
val parsedValue = try {
52+
candidateSegment.typeParser.invoke(requestedSegment)
53+
} catch (e: IllegalArgumentException) {
54+
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
55+
return null
56+
}
57+
args[candidateSegment.stringValue] = parsedValue
58+
} else if (requestedSegment != candidateSegment.stringValue) {
59+
// if it's path arg is not the expected type, its not a match
60+
return null
61+
}
62+
}
63+
// match queries (if any)
64+
request.queries.forEach { query ->
65+
val name = query.key
66+
// If the pattern does not define this query parameter, ignore it.
67+
// This prevents a NullPointerException.
68+
val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach
69+
70+
val queryParsedValue = try {
71+
queryStringParser.invoke(query.value)
72+
} catch (e: IllegalArgumentException) {
73+
Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e)
74+
return null
75+
}
76+
args[name] = queryParsedValue
77+
}
78+
// provide the serializer of the matching key and map of arg names to parsed arg values
79+
return DeepLinkMatchResult(deepLinkPattern.serializer, args)
80+
}
81+
}
82+
83+
/**
84+
* Created when a requested deeplink matches with a supported deeplink
85+
*
86+
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
87+
* @param serializer serializer for [T]
88+
* @param args The map of argument name to argument value. The value is expected to have already
89+
* been parsed from the raw url string back into its proper KType as declared in [T].
90+
* Includes arguments for all parts of the uri - path, query, etc.
91+
* */
92+
internal data class DeepLinkMatchResult<T : NavKey>(val serializer: KSerializer<T>, val args: Map<String, Any>)
93+
94+
const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"

0 commit comments

Comments
 (0)