Refactor app to navigation3

pull/1902/head
Clara Fok 2 months ago
parent b96b3e9e40
commit 04fa1ff2b7

@ -68,10 +68,15 @@ android {
dependencies {
implementation(projects.feature.interests.api)
implementation(projects.feature.interests.impl)
implementation(projects.feature.foryou.api)
implementation(projects.feature.foryou.impl)
implementation(projects.feature.bookmarks.api)
implementation(projects.feature.bookmarks.impl)
implementation(projects.feature.topic.api)
implementation(projects.feature.topic.impl)
implementation(projects.feature.search.api)
implementation(projects.feature.search.impl)
implementation(projects.feature.settings.api)
implementation(projects.core.common)
@ -81,10 +86,13 @@ dependencies {
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.navigation)
implementation(projects.sync.work)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)

@ -32,6 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
@ -41,6 +42,7 @@ import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.ui.NiaApp
import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState
import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme
@ -72,9 +74,14 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository
private val viewModel: MainActivityViewModel by viewModels()
@Inject
lateinit var niaBackStack: NiaBackStack
@Inject
lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<Any>.() -> Unit>
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
@ -137,6 +144,7 @@ class MainActivity : ComponentActivity() {
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
niaBackStack = niaBackStack,
)
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
@ -150,7 +158,10 @@ class MainActivity : ComponentActivity() {
androidTheme = themeSettings.androidTheme,
disableDynamicTheming = themeSettings.disableDynamicTheming,
) {
NiaApp(appState)
NiaApp(
appState,
entryProviderBuilders
)
}
}
}

@ -30,5 +30,5 @@ object NiaAppNavigation {
@Provides
@Singleton
fun provideNiaBackStack(): NiaBackStack =
NiaBackStack(startKey = TopLevelDestination.FOR_YOU)
NiaBackStack(startKey = TopLevelDestination.FOR_YOU.key)
}

@ -0,0 +1,42 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.EntryProviderBuilder
import androidx.navigation3.runtime.NavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
@Composable
fun NiaNavDisplay(
niaBackStack: NiaBackStack,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<Any>.() -> Unit>,
) {
NavDisplay(
backStack = niaBackStack.backStack,
onBack = { niaBackStack.removeLast() },
entryProvider = entryProvider {
entryProviderBuilders.forEach { builder ->
builder()
}
},
)
}

@ -28,7 +28,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigat
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen
import com.google.samples.apps.nowinandroid.feature.interests.impl.interestsListDetailScreen
/**
* Top-level navigation graph. Navigation is organized as explained at

@ -50,6 +50,7 @@ enum class TopLevelDestination(
@StringRes val titleTextId: Int,
val route: KClass<*>,
val baseRoute: KClass<*> = route,
val key: Any
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
@ -58,6 +59,7 @@ enum class TopLevelDestination(
titleTextId = R.string.app_name,
route = ForYouRoute::class,
baseRoute = ForYouBaseRoute::class,
key = ForYouBaseRoute
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
@ -65,6 +67,7 @@ enum class TopLevelDestination(
iconTextId = bookmarksR.string.feature_bookmarks_impl_title,
titleTextId = bookmarksR.string.feature_bookmarks_impl_title,
route = BookmarksRoute::class,
key = BookmarksRoute
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
@ -72,5 +75,6 @@ enum class TopLevelDestination(
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
route = InterestsRoute::class,
key = InterestsRoute(null)
),
}

@ -33,15 +33,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -63,6 +62,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation3.runtime.EntryProviderBuilder
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
@ -71,8 +71,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.NiaNavDisplay
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR
@ -80,6 +81,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR
@Composable
fun NiaApp(
appState: NiaAppState,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<Any>.() -> Unit>,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
@ -109,15 +111,16 @@ fun NiaApp(
)
}
}
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
NiaApp(
appState = appState,
entryProviderBuilders = entryProviderBuilders,
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
}
}
}
@ -129,7 +132,7 @@ fun NiaApp(
)
internal fun NiaApp(
appState: NiaAppState,
snackbarHostState: SnackbarHostState,
entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder<Any>.() -> Unit>,
showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit,
@ -138,7 +141,8 @@ internal fun NiaApp(
) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
.collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
val currentTopLevelKey = appState.currentTopLevelDestination
if (showSettingsDialog) {
SettingsDialog(
@ -146,12 +150,16 @@ internal fun NiaApp(
)
}
val snackbarHostState = LocalSnackbarHostState.current
NiaNavigationSuiteScaffold(
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
.isRouteInHierarchy(destination.baseRoute)
// val selected = currentDestination
// .isRouteInHierarchy(destination.baseRoute)
val selected = destination.key == currentTopLevelKey
println("cfok destination:$destination, currentDest:$currentTopLevelKey")
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
@ -225,7 +233,7 @@ internal fun NiaApp(
containerColor = Color.Transparent,
),
onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { appState.navigateToSearch() },
onNavigationClick = { appState.navigateToSearchNav3() },
)
}
@ -239,15 +247,13 @@ internal fun NiaApp(
},
),
) {
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
},
// NiaNavHost(
// appState = appState,
// onShowSnackbar = onShowSnackbar,
// )
NiaNavDisplay(
niaBackStack = appState.niaBackStack,
entryProviderBuilders,
)
}

@ -18,26 +18,19 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
@ -55,11 +48,13 @@ fun rememberNiaAppState(
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
niaBackStack: NiaBackStack,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState {
NavigationTrackingSideEffect(navController)
return remember(
niaBackStack,
navController,
coroutineScope,
networkMonitor,
@ -67,6 +62,7 @@ fun rememberNiaAppState(
timeZoneMonitor,
) {
NiaAppState(
niaBackStack = niaBackStack,
navController = navController,
coroutineScope = coroutineScope,
networkMonitor = networkMonitor,
@ -78,6 +74,7 @@ fun rememberNiaAppState(
@Stable
class NiaAppState(
val niaBackStack: NiaBackStack,
val navController: NavHostController,
coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor,
@ -86,24 +83,25 @@ class NiaAppState(
) {
private val previousDestination = mutableStateOf<NavDestination?>(null)
val currentDestination: NavDestination?
@Composable get() {
// Collect the currentBackStackEntryFlow as a state
val currentEntry = navController.currentBackStackEntryFlow
.collectAsState(initial = null)
// Fallback to previousDestination if currentEntry is null
return currentEntry.value?.destination.also { destination ->
if (destination != null) {
previousDestination.value = destination
}
} ?: previousDestination.value
}
// val currentDestination: NavDestination?
// @Composable get() {
// // Collect the currentBackStackEntryFlow as a state
// val currentEntry = navController.currentBackStackEntryFlow
// .collectAsState(initial = null)
//
// // Fallback to previousDestination if currentEntry is null
// return currentEntry.value?.destination.also { destination ->
// if (destination != null) {
// previousDestination.value = destination
// }
// } ?: previousDestination.value
// }
val currentTopLevelDestination: TopLevelDestination?
@Composable get() {
return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
currentDestination?.hasRoute(route = topLevelDestination.route) == true
topLevelDestination.key == niaBackStack.currentTopLevelKey
// currentDestination?.hasRoute(route = topLevelDestination.route) == true
}
}
@ -152,31 +150,37 @@ class NiaAppState(
*
* @param topLevelDestination: The destination the app needs to navigate to.
*/
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
trace("Navigation: ${topLevelDestination.name}") {
val topLevelNavOptions = navOptions {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
}
}
fun navigateToTopLevelDestination(
topLevelDestination: TopLevelDestination,
) {
niaBackStack.navigateToTopLevelDestination(topLevelDestination.key)
// trace("Navigation: ${topLevelDestination.name}") {
// val topLevelNavOptions = navOptions {
// // Pop up to the start destination of the graph to
// // avoid building up a large stack of destinations
// // on the back stack as users select items
// popUpTo(navController.graph.findStartDestination().id) {
// saveState = true
// }
// // Avoid multiple copies of the same destination when
// // reselecting the same item
// launchSingleTop = true
// // Restore state when reselecting a previously selected item
// restoreState = true
// }
//
// when (topLevelDestination) {
// FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
// BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
// INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
// }
// }
}
fun navigateToSearch() = navController.navigateToSearch()
fun navigateToSearchNav3() = niaBackStack.navigateToSearch(
onInterestsClick = { navigateToTopLevelDestination(INTERESTS) }
)
}
/**
@ -195,4 +199,4 @@ private fun NavigationTrackingSideEffect(navController: NavHostController) {
navController.removeOnDestinationChangedListener(listener)
}
}
}
}

@ -1,43 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
const val TOPIC_ID_KEY = "selectedTopicId"
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_KEY] = topicId
}
}

@ -1,244 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.VerticalDragHandle
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.interests.api.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.math.max
@Serializable internal object TopicPlaceholderRoute
fun NavGraphBuilder.interestsListDetailScreen() {
composable<InterestsRoute> {
InterestsListDetailScreen()
}
}
@Composable
internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(),
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen(
selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick,
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
internal fun InterestsListDetailScreen(
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
windowAdaptiveInfo: WindowAdaptiveInfo,
) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo),
initialDestinationHistory = listOfNotNull(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
selectedTopicId != null
},
),
)
val coroutineScope = rememberCoroutineScope()
val paneExpansionState = rememberPaneExpansionState(
anchors = listOf(
PaneExpansionAnchor.Proportion(0f),
PaneExpansionAnchor.Proportion(0.5f),
PaneExpansionAnchor.Proportion(1f),
),
)
ThreePaneScaffoldPredictiveBackHandler(
listDetailNavigator,
BackNavigationBehavior.PopUntilScaffoldValueChange,
)
BackHandler(
paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(0f) &&
listDetailNavigator.isListPaneVisible() &&
listDetailNavigator.isDetailPaneVisible(),
) {
coroutineScope.launch {
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(1f))
}
}
var topicRoute by remember {
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
}
fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId)
topicRoute = TopicRoute(id = topicId)
coroutineScope.launch {
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
if (paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(1f)) {
coroutineScope.launch {
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(0f))
}
}
}
val mutableInteractionSource = remember { MutableInteractionSource() }
val minPaneWidth = 300.dp
NavigableListDetailPaneScaffold(
navigator = listDetailNavigator,
listPane = {
AnimatedPane {
Box(
modifier = Modifier.clipToBounds()
.layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
val placeable = measurable.measure(
constraints.copy(
minWidth = minPaneWidth.roundToPx(),
maxWidth = width,
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(
x = 0,
y = 0,
)
}
},
) {
InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane,
shouldHighlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
)
}
}
},
detailPane = {
AnimatedPane {
Box(
modifier = Modifier.clipToBounds()
.layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
val placeable = measurable.measure(
constraints.copy(
minWidth = minPaneWidth.roundToPx(),
maxWidth = width,
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(
x = constraints.maxWidth -
max(constraints.maxWidth, placeable.width),
y = 0,
)
}
},
) {
AnimatedContent(topicRoute) { route ->
when (route) {
is TopicRoute -> {
TopicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = {
coroutineScope.launch {
listDetailNavigator.navigateBack()
}
},
onTopicClick = ::onTopicClickShowDetailPane,
viewModel = hiltViewModel<TopicViewModel, TopicViewModel.Factory>(
key = route.id,
) { factory ->
factory.create(route.id)
},
)
}
is TopicPlaceholderRoute -> {
TopicDetailPlaceholder()
}
}
}
}
}
},
paneExpansionState = paneExpansionState,
paneExpansionDragHandle = {
VerticalDragHandle(
modifier = Modifier.paneExpansionDraggable(
state = paneExpansionState,
minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
interactionSource = mutableInteractionSource,
semanticsProperties = paneExpansionState.defaultDragHandleSemantics(),
),
interactionSource = mutableInteractionSource,
)
},
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

@ -1,204 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.ui.interests2pane.InterestsListDetailScreen
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import kotlin.test.assertTrue
import com.google.samples.apps.nowinandroid.feature.topic.api.R as FeatureTopicR
private const val EXPANDED_WIDTH = "w1200dp-h840dp"
private const val COMPACT_WIDTH = "w412dp-h915dp"
@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
class InterestsListDetailScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject
lateinit var topicsRepository: TopicsRepository
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first().sortedBy { it.name }
}
// The strings used for matching in these tests.
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest)
private val listPaneTag = "interests:topics"
private val Topic.testTag
get() = "topic:${this.id}"
@Before
fun setup() {
hiltRule.inject()
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
}
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
onNodeWithTag(listPaneTag).assertIsNotDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
var unhandledBackPress = false
composeTestRule.apply {
setContent {
NiaTheme {
// Back press should not be handled by the two pane layout, and thus
// "fall through" to this BackHandler.
BackHandler {
unhandledBackPress = true
}
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
assertTrue(unhandledBackPress)
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed()
}
}
}
private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -85,7 +85,7 @@ class NiaAppStateTest {
}
// Update currentDestination whenever it changes
currentDestination = state.currentDestination?.route
currentDestination = state.niaBackStack.currentKey
// Navigate to destination b once
LaunchedEffect(Unit) {

@ -43,6 +43,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
"implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.navigation3.runtime").get())
"implementation"(libs.findLibrary("androidx.tracing.ktx").get())
"implementation"(libs.findLibrary("kotlinx.serialization.json").get())

@ -0,0 +1,17 @@
#
# Copyright 2025 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
sdk = 35

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@ -35,9 +36,12 @@ class NiaBackStack @Inject constructor(
)
// Expose the current top level route for consumers
var topLevelKey by mutableStateOf(startKey)
var currentTopLevelKey by mutableStateOf(startKey)
private set
internal val currentKey: Any
@Composable get() = topLevelStacks[currentTopLevelKey]!!.last()
private fun updateBackStack() =
backStack.apply {
clear()
@ -56,21 +60,21 @@ class NiaBackStack @Inject constructor(
}
}
}
topLevelKey = key
currentTopLevelKey = key
updateBackStack()
}
fun navigate(key: Any){
println("cfok navigate $key")
topLevelStacks[topLevelKey]?.add(key)
topLevelStacks[currentTopLevelKey]?.add(key)
updateBackStack()
}
fun removeLast(){
val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull()
val removedKey = topLevelStacks[currentTopLevelKey]?.removeLastOrNull()
// If the removed key was a top level key, remove the associated top level stack
topLevelStacks.remove(removedKey)
topLevelKey = topLevelStacks.keys.last()
currentTopLevelKey = topLevelStacks.keys.last()
updateBackStack()
}

Loading…
Cancel
Save