Implement list/detail view with the new material3-adaptive API

Change-Id: I13cca7db13411794e333d34f6edacf594586ef6d
feature/list-detail-pane-scaffold
Miłosz Moczkowski 11 months ago
parent 335a7ec68c
commit dfff80640f

@ -17,14 +17,20 @@
package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicNavigationRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
@ -49,23 +55,35 @@ fun NiaNavHost(
startDestination = startDestination,
modifier = modifier,
) {
forYouScreen(onTopicClick = navController::navigateToTopic)
forYouScreen(onTopicClick = navController::navigateToInterestsGraph)
bookmarksScreen(
onTopicClick = navController::navigateToTopic,
onTopicClick = navController::navigateToInterestsGraph,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToTopic,
onTopicClick = navController::navigateToInterestsGraph,
)
interestsGraph(
onTopicClick = navController::navigateToTopic,
nestedGraphs = {
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
detailsPane = { topicId ->
val nestedNavController = rememberNavController()
NavHost(
navController = nestedNavController,
startDestination = topicNavigationRoute,
) {
topicScreen(onTopicClick = nestedNavController::navigateToTopic)
}
LaunchedEffect(topicId) {
nestedNavController.navigateToTopic(
topicId,
navOptions {
popUpTo(nestedNavController.graph.findStartDestination().id) {
inclusive = true
}
},
)
}
},
)
}

@ -175,7 +175,7 @@ class NiaAppState(
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(navOptions = topLevelNavOptions)
}
}
}

@ -22,3 +22,9 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.feature.interests"
}
dependencies {
implementation(libs.androidx.compose.material3.adaptive) {
isTransitive = false
}
}

@ -74,7 +74,10 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = followableTopicTestData),
uiState = InterestsUiState.Interests(
selectedTopicId = null,
topics = followableTopicTestData,
),
)
}
@ -108,8 +111,9 @@ class InterestsScreenTest {
private fun InterestsScreen(uiState: InterestsUiState) {
InterestsScreen(
uiState = uiState,
followTopic = { _, _ -> },
onTopicClick = {},
followTopic = { _, _ -> /* no-op */ },
onTopicClick = { /* no-op */ },
detailsPane = { /* no-op */ },
)
}
}

@ -36,7 +36,7 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
@Composable
internal fun InterestsRoute(
onTopicClick: (String) -> Unit,
detailsPane: @Composable (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel(),
) {
@ -45,7 +45,8 @@ internal fun InterestsRoute(
InterestsScreen(
uiState = uiState,
followTopic = viewModel::followTopic,
onTopicClick = onTopicClick,
onTopicClick = viewModel::onTopicClick,
detailsPane = detailsPane,
modifier = modifier,
)
}
@ -55,6 +56,7 @@ internal fun InterestsScreen(
uiState: InterestsUiState,
followTopic: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
detailsPane: @Composable (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -67,13 +69,17 @@ internal fun InterestsScreen(
modifier = modifier,
contentDesc = stringResource(id = R.string.loading),
)
is InterestsUiState.Interests ->
TopicsTabContent(
topics = uiState.topics,
selectedTopicId = uiState.selectedTopicId,
onTopicClick = onTopicClick,
onFollowButtonClick = followTopic,
detailsPane = detailsPane,
modifier = modifier,
)
is InterestsUiState.Empty -> InterestsEmptyScreen()
}
}
@ -96,9 +102,11 @@ fun InterestsScreenPopulated(
InterestsScreen(
uiState = InterestsUiState.Interests(
topics = followableTopics,
selectedTopicId = followableTopics.first().topic.id,
),
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}
@ -113,6 +121,7 @@ fun InterestsScreenLoading() {
uiState = InterestsUiState.Loading,
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}
@ -127,6 +136,7 @@ fun InterestsScreenEmpty() {
uiState = InterestsUiState.Empty,
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}

@ -16,46 +16,57 @@
package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.navigation.topicIdArg
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class InterestsViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
val uiState: StateFlow<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
val uiState: StateFlow<InterestsUiState> = combine(
savedStateHandle.getStateFlow<String?>(topicIdArg, null),
getFollowableTopics(sortBy = TopicSortField.NAME),
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {
userDataRepository.setTopicIdFollowed(followedTopicId, followed)
}
}
fun onTopicClick(topicId: String) {
viewModelScope.launch {
savedStateHandle[topicIdArg] = topicId
}
}
}
sealed interface InterestsUiState {
data object Loading : InterestsUiState
data class Interests(
val selectedTopicId: String?,
val topics: List<FollowableTopic>,
) : InterestsUiState

@ -16,21 +16,27 @@
package com.google.samples.apps.nowinandroid.feature.interests
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.rememberListDetailPaneScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@ -40,44 +46,78 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollba
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun TopicsTabContent(
topics: List<FollowableTopic>,
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
detailsPane: @Composable (String) -> Unit,
) {
val listDetailPaneState = rememberListDetailPaneScaffoldState()
BackHandler(enabled = listDetailPaneState.canNavigateBack()) {
listDetailPaneState.navigateBack()
}
LaunchedEffect(selectedTopicId) {
if (selectedTopicId != null) {
listDetailPaneState.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
}
ListDetailPaneScaffold(
scaffoldState = listDetailPaneState,
listPane = {
ListPane(
topics = topics,
onTopicClick = onTopicClick,
onFollowButtonClick = onFollowButtonClick,
)
},
detailPane = {
if (selectedTopicId != null) {
detailsPane(selectedTopicId)
}
},
modifier = modifier,
)
}
@Composable
private fun ListPane(
topics: List<FollowableTopic>,
onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true,
) {
Box(
modifier = modifier
.fillMaxWidth(),
modifier = modifier.fillMaxSize(),
) {
val scrollableState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(horizontal = 24.dp)
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
modifier = Modifier.testTag("interests:topics"),
state = scrollableState,
) {
topics.forEach { followableTopic ->
items(
items = topics,
key = { followableTopic -> followableTopic.topic.id },
) { followableTopic ->
val topicId = followableTopic.topic.id
item(key = topicId) {
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
if (withBottomSpacer) {
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
val scrollbarState = scrollableState.scrollbarState(

@ -16,31 +16,40 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph"
const val interestsRoute = "interests_route"
internal const val topicIdArg = "topicId"
const val interestsRoute = "interests_route?$topicIdArg={$topicIdArg}"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
fun NavController.navigateToInterestsGraph(
topicId: String? = null,
navOptions: NavOptions? = null,
) {
if (topicId == null) {
navigate("interests_route", navOptions)
} else {
navigate("interests_route?$topicIdArg=$topicId", navOptions)
}
}
fun NavGraphBuilder.interestsGraph(
onTopicClick: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit,
detailsPane: @Composable (String) -> Unit,
) {
navigation(
route = INTERESTS_GRAPH_ROUTE_PATTERN,
startDestination = interestsRoute,
composable(
route = interestsRoute,
arguments = listOf(
navArgument(topicIdArg) {
defaultValue = null
nullable = true
},
),
) {
composable(route = interestsRoute) {
InterestsRoute(onTopicClick)
}
nestedGraphs()
InterestsRoute(detailsPane)
}
}

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -53,6 +54,7 @@ class InterestsViewModelTest {
@Before
fun setup() {
viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
)
@ -93,7 +95,7 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics),
InterestsUiState.Interests(selectedTopicId = null, topics = testOutputTopics),
viewModel.uiState.value,
)
@ -123,7 +125,7 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testInputTopics),
InterestsUiState.Interests(selectedTopicId = null, topics = testInputTopics),
viewModel.uiState.value,
)

@ -55,7 +55,6 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
@ -75,7 +74,6 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
@ -100,7 +98,6 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Success(userNewsResourcesTestData),
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
@ -123,7 +120,6 @@ class TopicScreenTest {
newsUiState = NewsUiState.Success(
userNewsResourcesTestData,
),
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },

@ -32,12 +32,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -57,7 +54,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadi
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
@ -69,8 +65,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
fun TopicRoute(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
@ -83,7 +78,6 @@ internal fun TopicRoute(
topicUiState = topicUiState,
newsUiState = newsUiState,
modifier = modifier,
onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews,
onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
@ -96,7 +90,6 @@ internal fun TopicRoute(
internal fun TopicScreen(
topicUiState: TopicUiState,
newsUiState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
onTopicClick: (String) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
@ -112,9 +105,6 @@ internal fun TopicScreen(
state = state,
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicUiState) {
TopicUiState.Loading -> item {
NiaLoadingWheel(
@ -127,7 +117,6 @@ internal fun TopicScreen(
is TopicUiState.Success -> {
item {
TopicToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = topicUiState.followableTopic,
)
@ -270,24 +259,12 @@ private fun TopicBodyPreview() {
private fun TopicToolbar(
uiState: FollowableTopic,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onFollowClick: (Boolean) -> Unit = {},
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 32.dp),
horizontalArrangement = Arrangement.End,
modifier = modifier.fillMaxWidth(),
) {
IconButton(onClick = { onBackClick() }) {
Icon(
imageVector = NiaIcons.ArrowBack,
contentDescription = stringResource(
id = com.google.samples.apps.nowinandroid.core.ui.R.string.back,
),
)
}
val selected = uiState.isFollowed
NiaFilterChip(
selected = selected,
@ -314,7 +291,6 @@ fun TopicScreenPopulated(
TopicScreen(
topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]),
newsUiState = NewsUiState.Success(userNewsResources),
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
@ -332,7 +308,6 @@ fun TopicScreenLoading() {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},

@ -20,6 +20,7 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
@ -32,29 +33,27 @@ private val URL_CHARACTER_ENCODING = UTF_8.name()
@VisibleForTesting
internal const val topicIdArg = "topicId"
const val topicNavigationRoute = "topic_route/{$topicIdArg}"
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle) :
this(URLDecoder.decode(checkNotNull(savedStateHandle[topicIdArg]), URL_CHARACTER_ENCODING))
}
fun NavController.navigateToTopic(topicId: String) {
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptions? = null) {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
this.navigate("topic_route/$encodedId") {
launchSingleTop = true
}
navigate("topic_route/$encodedId", navOptions)
}
fun NavGraphBuilder.topicScreen(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
composable(
route = "topic_route/{$topicIdArg}",
route = topicNavigationRoute,
arguments = listOf(
navArgument(topicIdArg) { type = NavType.StringType },
),
) {
TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick)
TopicRoute(onTopicClick = onTopicClick)
}
}

Loading…
Cancel
Save