Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Jetchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Jetchat is still in under development, and some features are not yet implemented
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)

### Architecture Components
[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
[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`. It uses the Material `ListDetailSceneStrategy` for an adaptive list-detail layout: on wide windows, such as tablets and unfolded foldables in landscape, the chat list is shown next to the conversation, and opening a profile shows it in an extra pane. On narrower windows, one pane is shown at a time and the chat list stays in the navigation drawer, so the phone flow is unchanged. 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 and chat list derive their selected item from the key on top of it. The profile entry passes the `userId` of its key to a
[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).

### Back button handling
Expand Down
3 changes: 3 additions & 0 deletions Jetchat/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ dependencies {

implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation3)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.util)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ class NavigationTest {
// Press back
Espresso.pressBack()

// Check that we're home
// Check that we're home and the profile was popped
assertConversationIsDisplayed()
composeTestRule
.onNodeWithText(composeTestRule.activity.getString(R.string.display_name))
.assertDoesNotExist()
}

/**
Expand All @@ -89,29 +92,69 @@ class NavigationTest {
}

private fun assertConversationIsDisplayed() {
// The channel name is shown in the conversation's app bar (and in the drawer, which is excluded)
composeTestRule.onNode(hasText("composers") and !isInDrawer()).assertIsDisplayed()
// The channel name is shown in the conversation's app bar (and in the chat list, which
// is excluded)
composeTestRule.onNode(hasText("composers") and !isInAnyChatList()).assertIsDisplayed()
}

private fun navigateToProfile(name: String) {
composeTestRule.onNodeWithContentDescription(
composeTestRule.activity.getString(R.string.navigation_drawer_open),
).performClick()
if (!chatListIsPane) {
composeTestRule.onNodeWithContentDescription(
composeTestRule.activity.getString(R.string.navigation_drawer_open),
).performClick()
}

composeTestRule.onNode(hasText(name) and isInChatList()).performClick()
}

private fun navigateToHomeFromProfile() {
if (chatListIsPane) {
// On two-pane windows the profile hides the list pane; the channel name brings it
// back. On wider windows the list pane stays next to the conversation.
if (!isListPaneDisplayed()) {
composeTestRule.onNodeWithContentDescription(
composeTestRule.activity.getString(R.string.show_chat_list),
).performClick()
}
} else {
composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput {
swipeRight()
}
}
composeTestRule.onNode(hasText("composers") and isInChatList()).performClick()
}

/**
* Whether the chat list is shown in the list pane next to the conversation (wide windows)
* rather than in the navigation drawer (single-pane windows).
*
* Resolved on first use, which must happen while the conversation is on top of the back
* stack: on two-pane windows a profile hides the list pane.
*/
private val chatListIsPane: Boolean by lazy { isListPaneDisplayed() }

composeTestRule.onNode(hasText(name) and isInDrawer()).performClick()
private fun isListPaneDisplayed(): Boolean = try {
composeTestRule.onNode(isListPane()).assertIsDisplayed()
true
} catch (e: AssertionError) {
false
}

private fun isInDrawer() = hasAnyAncestor(isDrawer())
/**
* Matches nodes in the container that currently shows the chat list. The closed drawer keeps
* its content composed on wide windows, so the two containers are never matched together.
*/
private fun isInChatList() = hasAnyAncestor(if (chatListIsPane) isListPane() else isDrawer())

private fun isInAnyChatList() = hasAnyAncestor(isDrawer() or isListPane())

private fun isDrawer() = SemanticsMatcher.expectValue(
SemanticsProperties.PaneTitle,
composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu),
)

private fun navigateToHomeFromProfile() {
composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput {
swipeRight()
}
composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick()
}
private fun isListPane() = SemanticsMatcher.expectValue(
SemanticsProperties.PaneTitle,
composeTestRule.activity.getString(R.string.chat_list),
)
}
163 changes: 147 additions & 16 deletions Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,47 @@

package com.example.compose.jetchat

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue.Closed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.LocalListDetailSceneScope
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.compose.jetchat.components.JetchatDrawer
import com.example.compose.jetchat.components.JetchatDrawerContent
import com.example.compose.jetchat.conversation.ConversationContent
import com.example.compose.jetchat.conversation.ConversationViewModel
import com.example.compose.jetchat.navigation.ChatList
import com.example.compose.jetchat.navigation.Conversation
import com.example.compose.jetchat.navigation.Profile
import com.example.compose.jetchat.navigation.openConversation
Expand All @@ -46,30 +72,68 @@ import kotlinx.coroutines.launch
* The back stack is the single source of truth for navigation. The drawer derives its selection
* from it and sends navigation events back to it, following unidirectional data flow.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun JetchatApp() {
// The back stack survives configuration changes and process death
val backStack = rememberNavBackStack(Conversation("composers"))
val backStack = rememberNavBackStack(ChatList, Conversation("composers"))
val drawerState = rememberDrawerState(initialValue = Closed)
val scope = rememberCoroutineScope()

// Override the defaults so that there isn't a horizontal space between the panes
val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dturner or @bsagmoe Could you review this usage of Nav3?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

val directive = remember(windowAdaptiveInfo) {
calculatePaneScaffoldDirective(windowAdaptiveInfo)
.copy(horizontalPartitionSpacerSize = 0.dp)
}
val isMultiPane = directive.maxHorizontalPartitions > 1
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(directive = directive)

LaunchedEffect(isMultiPane) {
if (isMultiPane && drawerState.isOpen) {
drawerState.close()
}
}

val selectedKey = backStack.last()
val onNavigate: (NavKey) -> Unit = { key ->
when (key) {
is Conversation -> backStack.openConversation(key.channel)
is Profile -> backStack.openProfile(key.userId)
}
scope.launch {
drawerState.close()
}
}

// On single-pane windows the chat list is shown in the drawer instead of a screen. The
// conversation is never popped, so the displayed back stack is never empty.
val displayBackStack = if (isMultiPane) {
backStack
} else {
backStack.filter { it !is ChatList }.also {
check(it.isNotEmpty()) { "A conversation must always be on the back stack" }
}
}

JetchatDrawer(
// Derived from the back stack, so it can't get out of sync with the displayed screen
selectedKey = backStack.last(),
onNavigate = { key ->
when (key) {
is Conversation -> backStack.openConversation(key.channel)
is Profile -> backStack.openProfile(key.userId)
}
selectedKey = selectedKey,
onNavigate = onNavigate,
drawerState = drawerState,
// The list pane replaces the drawer on wide windows
gesturesEnabled = !isMultiPane,
) {
BackHandler(enabled = drawerState.isOpen) {
scope.launch {
drawerState.close()
}
},
drawerState = drawerState,
) {
}

NavDisplay(
backStack = backStack,
backStack = displayBackStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(listDetailStrategy),
// Scope the ViewModels to their entry. The ViewModelStoreNavEntryDecorator
// requires the default SaveableStateHolderNavEntryDecorator, which saves the
// state of each entry.
Expand All @@ -78,22 +142,71 @@ fun JetchatApp() {
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<Conversation> { key ->
entry<ChatList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { ChatPlaceholder() },
),
) {
val chatListTitle = stringResource(R.string.chat_list)
Surface(
modifier = Modifier
.fillMaxSize()
.semantics { paneTitle = chatListTitle },
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
) {
JetchatDrawerContent(
selectedKey = selectedKey,
onNavigate = onNavigate,
)
}
}
entry<Conversation>(
metadata = ListDetailSceneStrategy.detailPane(),
) { key ->
// The channel comes from the key, so pass it when the ViewModel of this
// entry is created
val viewModel = viewModel { ConversationViewModel(channel = key.channel) }
// The channel name shows the chat list: it opens the drawer on single-pane
// windows, where there is no list-detail scene, and pops the profile that
// hides the list pane on wide windows. Whether the list pane is shown comes
// from the scaffold, as it depends on the number of panes: on 3-pane windows
// it stays next to the conversation while a profile is shown.
val sceneScope = LocalListDetailSceneScope.current
val isListPaneShown = sceneScope?.scaffoldTransitionScope
?.scaffoldStateTransition?.targetState
?.get(ListDetailPaneScaffoldRole.List) == PaneAdaptedValue.Expanded
ConversationContent(
uiState = viewModel.uiState,
navigateToProfile = { userId -> backStack.openProfile(userId) },
onNavIconPressed = {
scope.launch {
drawerState.open()
onNavIconPressed = when {
sceneScope == null -> {
{
scope.launch {
drawerState.open()
}
}
}

isListPaneShown -> null

else -> {
{ backStack.removeLastOrNull() }
}
},
navIconContentDescription = stringResource(
if (sceneScope == null) {
R.string.navigation_drawer_open
} else {
R.string.show_chat_list
},
),
onMessageLikeToggled = viewModel::toggleLike,
)
}
entry<Profile> { key ->
entry<Profile>(
metadata = ListDetailSceneStrategy.extraPane(),
) { key ->
// The user id comes from the key, so set it when the ViewModel of this
// entry is created
val viewModel = viewModel {
Expand All @@ -113,3 +226,21 @@ fun JetchatApp() {
)
}
}

/** Shown in the detail pane when no conversation is selected. */
@Composable
private fun ChatPlaceholder() {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.select_a_chat),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(24.dp),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,19 @@ import com.example.compose.jetchat.theme.JetchatTheme
fun JetchatAppBar(
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
onNavIconPressed: () -> Unit = { },
onNavIconPressed: (() -> Unit)? = { },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think this pattern is generally encouraged, I would rather pass an empty navigationIcon instead of checking this is null

title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
navigationIcon: @Composable () -> Unit = {
JetchatIcon(
contentDescription = stringResource(id = R.string.navigation_drawer_open),
modifier = Modifier
.size(64.dp)
.clickable(onClick = onNavIconPressed)
.padding(16.dp),
)
if (onNavIconPressed != null) {
JetchatIcon(
contentDescription = stringResource(id = R.string.navigation_drawer_open),
modifier = Modifier
.size(64.dp)
.clickable(onClick = onNavIconPressed)
.padding(16.dp),
)
}
},
) {
TopAppBar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ fun JetchatDrawer(
selectedKey: NavKey,
onNavigate: (NavKey) -> Unit,
drawerState: DrawerState = rememberDrawerState(initialValue = Closed),
gesturesEnabled: Boolean = true,
content: @Composable () -> Unit,
) {
JetchatTheme {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = gesturesEnabled,
drawerContent = {
ModalDrawerSheet(
drawerState = drawerState,
Expand Down
Loading
Loading