Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f75013e

Browse files
committedJun 20, 2020
Move navigation state to a ViewModel with SavedStateHandler to survive process death.
1 parent e928141 commit f75013e

File tree

13 files changed

+308
-78
lines changed

13 files changed

+308
-78
lines changed
 

‎JetNews/app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ dependencies {
7575
implementation 'androidx.activity:activity-ktx:1.1.0'
7676
implementation 'androidx.core:core-ktx:1.5.0-alpha01'
7777

78+
implementation "androidx.ui:ui-livedata:$compose_version"
79+
80+
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.0-alpha04"
81+
7882
androidTestImplementation 'junit:junit:4.13'
7983
androidTestImplementation 'androidx.test:rules:1.2.0'
8084
androidTestImplementation 'androidx.test:runner:1.2.0'

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ package com.example.jetnews
1818

1919
import android.content.Context
2020
import androidx.compose.Composable
21+
import androidx.compose.remember
22+
import androidx.lifecycle.SavedStateHandle
2123
import androidx.ui.material.MaterialTheme
2224
import androidx.ui.material.Surface
2325
import androidx.ui.test.ComposeTestRule
@@ -26,23 +28,25 @@ import androidx.ui.test.findAll
2628
import androidx.ui.test.hasSubstring
2729
import com.example.jetnews.ui.JetnewsApp
2830
import com.example.jetnews.ui.JetnewsStatus
29-
import com.example.jetnews.ui.Screen
31+
import com.example.jetnews.ui.NavigationViewModel
3032

3133
/**
3234
* Launches the app from a test context
3335
*/
3436
fun ComposeTestRule.launchJetNewsApp(context: Context) {
3537
setContent {
3638
JetnewsStatus.resetState()
37-
JetnewsApp(TestAppContainer(context))
39+
JetnewsApp(
40+
TestAppContainer(context),
41+
remember { NavigationViewModel(SavedStateHandle()) }
42+
)
3843
}
3944
}
4045

4146
/**
4247
* Resets the state of the app. Needs to be executed in Compose code (within a frame)
4348
*/
4449
fun JetnewsStatus.resetState() {
45-
currentScreen = Screen.Home
4650
favorites.clear()
4751
selectedTopics.clear()
4852
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020 The Android Open Source Project
2+
* Copyright (C) 2020 The Android Open Source Project
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -53,9 +53,13 @@ import com.example.jetnews.ui.interests.InterestsScreen
5353
import com.example.jetnews.ui.theme.JetnewsTheme
5454

5555
@Composable
56-
fun JetnewsApp(appContainer: AppContainer) {
56+
fun JetnewsApp(
57+
appContainer: AppContainer,
58+
navigationViewModel: NavigationViewModel
59+
) {
5760
JetnewsTheme {
5861
AppContent(
62+
navigationViewModel = navigationViewModel,
5963
interestsRepository = appContainer.interestsRepository,
6064
postsRepository = appContainer.postsRepository
6165
)
@@ -64,17 +68,25 @@ fun JetnewsApp(appContainer: AppContainer) {
6468

6569
@Composable
6670
private fun AppContent(
71+
navigationViewModel: NavigationViewModel,
6772
postsRepository: PostsRepository,
6873
interestsRepository: InterestsRepository
6974
) {
70-
Crossfade(JetnewsStatus.currentScreen) { screen ->
75+
Crossfade(navigationViewModel.currentScreen) { screen ->
7176
Surface(color = MaterialTheme.colors.background) {
7277
when (screen) {
73-
is Screen.Home -> HomeScreen(postsRepository = postsRepository)
74-
is Screen.Interests -> InterestsScreen(interestsRepository = interestsRepository)
78+
is Screen.Home -> HomeScreen(
79+
navigateTo = navigationViewModel::navigateTo,
80+
postsRepository = postsRepository
81+
)
82+
is Screen.Interests -> InterestsScreen(
83+
navigateTo = navigationViewModel::navigateTo,
84+
interestsRepository = interestsRepository
85+
)
7586
is Screen.Article -> ArticleScreen(
7687
postId = screen.postId,
77-
postsRepository = postsRepository
88+
postsRepository = postsRepository,
89+
onBack = { navigationViewModel.onBack() }
7890
)
7991
}
8092
}
@@ -83,6 +95,7 @@ private fun AppContent(
8395

8496
@Composable
8597
fun AppDrawer(
98+
navigateTo: (Screen) -> Unit,
8699
currentScreen: Screen,
87100
closeDrawer: () -> Unit
88101
) {
@@ -191,7 +204,8 @@ private fun DrawerButton(
191204
fun PreviewJetnewsApp() {
192205
ThemedPreview {
193206
AppDrawer(
194-
currentScreen = JetnewsStatus.currentScreen,
207+
navigateTo = { },
208+
currentScreen = Screen.Home,
195209
closeDrawer = { }
196210
)
197211
}
@@ -202,7 +216,8 @@ fun PreviewJetnewsApp() {
202216
fun PreviewJetnewsAppDark() {
203217
ThemedPreview(darkTheme = true) {
204218
AppDrawer(
205-
currentScreen = JetnewsStatus.currentScreen,
219+
navigateTo = { },
220+
currentScreen = Screen.Home,
206221
closeDrawer = { }
207222
)
208223
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/Status.kt renamed to ‎JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsStatus.kt

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,8 @@
1717
package com.example.jetnews.ui
1818

1919
import androidx.compose.frames.ModelList
20-
import androidx.compose.getValue
21-
import androidx.compose.mutableStateOf
22-
import androidx.compose.setValue
23-
24-
/**
25-
* Class defining the screens we have in the app: home, article details and interests
26-
*/
27-
sealed class Screen {
28-
object Home : Screen()
29-
data class Article(val postId: String) : Screen()
30-
object Interests : Screen()
31-
}
3220

3321
object JetnewsStatus {
34-
var currentScreen by mutableStateOf<Screen>(Screen.Home)
3522
val favorites = ModelList<String>()
3623
val selectedTopics = ModelList<String>()
3724
}
38-
39-
/**
40-
* Temporary solution pending navigation support.
41-
*/
42-
fun navigateTo(destination: Screen) {
43-
JetnewsStatus.currentScreen = destination
44-
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,27 @@
1717
package com.example.jetnews.ui
1818

1919
import android.os.Bundle
20+
import androidx.activity.viewModels
2021
import androidx.appcompat.app.AppCompatActivity
2122
import androidx.ui.core.setContent
2223
import com.example.jetnews.JetnewsApplication
2324

2425
class MainActivity : AppCompatActivity() {
2526

27+
val navigationViewModel by viewModels<NavigationViewModel>()
28+
2629
override fun onCreate(savedInstanceState: Bundle?) {
2730
super.onCreate(savedInstanceState)
2831

2932
val appContainer = (application as JetnewsApplication).container
3033
setContent {
31-
JetnewsApp(appContainer = appContainer)
34+
JetnewsApp(appContainer, navigationViewModel)
35+
}
36+
}
37+
38+
override fun onBackPressed() {
39+
if (!navigationViewModel.onBack()) {
40+
super.onBackPressed()
3241
}
3342
}
3443
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright (C) 2020 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+
* http://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.ui
18+
19+
import android.os.Bundle
20+
import androidx.annotation.MainThread
21+
import androidx.compose.frames.ModelList
22+
import androidx.compose.getValue
23+
import androidx.compose.setValue
24+
import androidx.core.os.bundleOf
25+
import androidx.lifecycle.SavedStateHandle
26+
import androidx.lifecycle.ViewModel
27+
import com.example.jetnews.ui.Screen.Article
28+
import com.example.jetnews.ui.Screen.Home
29+
import com.example.jetnews.ui.Screen.Interests
30+
import com.example.jetnews.ui.ScreenName.ARTICLE
31+
import com.example.jetnews.ui.ScreenName.HOME
32+
import com.example.jetnews.ui.ScreenName.INTERESTS
33+
import com.example.jetnews.utils.getMutableStateOf
34+
35+
/**
36+
* Screen names (used for serialization)
37+
*/
38+
enum class ScreenName { HOME, INTERESTS, ARTICLE }
39+
40+
/**
41+
* Class defining the screens we have in the app: home, article details and interests
42+
*/
43+
sealed class Screen(val id: ScreenName) {
44+
object Home : Screen(HOME)
45+
object Interests : Screen(INTERESTS)
46+
data class Article(val postId: String) : Screen(ARTICLE)
47+
}
48+
49+
/**
50+
* Helpers for saving and loading a [Screen] object to a [Bundle].
51+
*
52+
* This allows us to persist navigation across process death, for example caused by a long video
53+
* call.
54+
*/
55+
private const val SIS_SCREEN = "sis_screen"
56+
private const val SIS_NAME = "screen_name"
57+
private const val SIS_POST = "post"
58+
59+
/**
60+
* Convert a screen to a bundle that can be stored in [SavedStateHandle]
61+
*/
62+
private fun Screen.toBundle(): Bundle {
63+
return bundleOf(SIS_NAME to id.name).also {
64+
// add extra keys for various types here
65+
if (this is Article) {
66+
it.putString(SIS_POST, postId)
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Read a bundle stored by [Screen.toBundle] and return desired screen.
73+
*
74+
* @return the parsed [Screen]
75+
* @throws IllegalArgumentException if the bundle could not be parsed
76+
*/
77+
private fun Bundle.toScreen(): Screen {
78+
val screenName = ScreenName.valueOf(getStringOrThrow(SIS_NAME))
79+
return when (screenName) {
80+
HOME -> Home
81+
INTERESTS -> Interests
82+
ARTICLE -> {
83+
val postId = getStringOrThrow(SIS_POST)
84+
Article(postId)
85+
}
86+
}
87+
}
88+
89+
/**
90+
* Throw [IllegalArgumentException] if key is not in bundle.
91+
*
92+
* @see Bundle.getString
93+
*/
94+
private fun Bundle.getStringOrThrow(key: String) =
95+
requireNotNull(getString(key)) { "Missing key '$key' in $this" }
96+
97+
/**
98+
* This is expected to be replaced by the navigation component, but for now handle navigation
99+
* manually.
100+
*
101+
* Instantiate this ViewModel at the scope that is fully-responsible for navigation, which in this
102+
* application is [MainActivity].
103+
*
104+
* This app has simplified navigation; the back stack is always [Home] or [Home, dest] and more
105+
* levels are not allowed. To use a similar pattern with a longer back stack, use a [ModelList] to
106+
* hold the back stack state.
107+
*/
108+
class NavigationViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
109+
/**
110+
* Hold the current screen in an observable, restored from savedStateHandle after process
111+
* death.
112+
*
113+
* mutableStateOf is an observable similar to LiveData that's designed to be read by compose. It
114+
* supports observability via property delegate syntax as shown here.
115+
*/
116+
var currentScreen: Screen by savedStateHandle.getMutableStateOf<Screen>(
117+
key = SIS_SCREEN,
118+
default = Home,
119+
save = { it.toBundle() },
120+
restore = { it.toScreen() }
121+
)
122+
private set // limit the writes to only inside this class.
123+
124+
/**
125+
* Go back (always to [Home]).
126+
*
127+
* Returns true if this call caused user-visible navigation. Will always return false
128+
* when [currentScreen] is [Home].
129+
*/
130+
@MainThread
131+
fun onBack(): Boolean {
132+
val wasHandled = currentScreen != Home
133+
currentScreen = Home
134+
return wasHandled
135+
}
136+
137+
/**
138+
* Navigate to requested [Screen].
139+
*
140+
* If the requested screen is not [Home], it will always create a back stack with one element:
141+
* ([Home] -> [screen]). More back entries are not supported in this app.
142+
*/
143+
@MainThread
144+
fun navigateTo(screen: Screen) {
145+
currentScreen = screen
146+
}
147+
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,23 @@ import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository
5353
import com.example.jetnews.data.posts.impl.post3
5454
import com.example.jetnews.data.successOr
5555
import com.example.jetnews.model.Post
56-
import com.example.jetnews.ui.Screen
5756
import com.example.jetnews.ui.ThemedPreview
5857
import com.example.jetnews.ui.effect.fetchPost
5958
import com.example.jetnews.ui.home.BookmarkButton
6059
import com.example.jetnews.ui.home.isFavorite
6160
import com.example.jetnews.ui.home.toggleBookmark
62-
import com.example.jetnews.ui.navigateTo
6361
import com.example.jetnews.ui.state.UiState
6462

6563
@Composable
66-
fun ArticleScreen(postId: String, postsRepository: PostsRepository) {
64+
fun ArticleScreen(postId: String, postsRepository: PostsRepository, onBack: () -> Unit) {
6765
val postsState = fetchPost(postId, postsRepository)
6866
if (postsState is UiState.Success<Post>) {
69-
ArticleScreen(postsState.data)
67+
ArticleScreen(postsState.data, onBack)
7068
}
7169
}
7270

7371
@Composable
74-
private fun ArticleScreen(post: Post) {
72+
private fun ArticleScreen(post: Post, onBack: () -> Unit) {
7573

7674
var showDialog by state { false }
7775
if (showDialog) {
@@ -89,7 +87,7 @@ private fun ArticleScreen(post: Post) {
8987
)
9088
},
9189
navigationIcon = {
92-
IconButton(onClick = { navigateTo(Screen.Home) }) {
90+
IconButton(onClick = onBack) {
9391
Icon(Icons.Filled.ArrowBack)
9492
}
9593
}
@@ -165,7 +163,7 @@ private fun sharePost(post: Post, context: Context) {
165163
fun PreviewArticle() {
166164
ThemedPreview {
167165
val post = loadFakePost(post3.id)
168-
ArticleScreen(post)
166+
ArticleScreen(post, {})
169167
}
170168
}
171169

@@ -174,7 +172,7 @@ fun PreviewArticle() {
174172
fun PreviewArticleDark() {
175173
ThemedPreview(darkTheme = true) {
176174
val post = loadFakePost(post3.id)
177-
ArticleScreen(post)
175+
ArticleScreen(post, {})
178176
}
179177
}
180178

0 commit comments

Comments
 (0)