Skip to content

[Jetchat] Add foldable support - #1735

Open
raystatic wants to merge 5 commits into
mainfrom
adaptive
Open

raystatic wants to merge 5 commits into
mainfrom
adaptive

Conversation

@raystatic

@raystatic raystatic commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Uses Nav3's ListDetailSceneStrategy (for an adaptive list-detail layout.

  • Wide windows (tablets, unfolded foldables in landscape): the chat list is the
    list pane, the conversation the detail pane, and profiles open in the extra
    pane. The drawer is disabled there; the list pane takes its place.
  • Single-pane windows: unchanged - ModalNavigationDrawer, the channel name
    opens it.
  • Tapping the channel name on wide windows pops a profile that hides the list.
  • Channels get their own fake data; droidcon-nyc shows an empty state instead
    of duplicating #composers (behaviour change vs main, where both channels
    showed the same messages).
  • Keyboard is only shown when the input is tapped

Screenshots

Folded phone (compact, unchanged)
Conversation Navigation drawer Profile
Conversation Drawer (channel name opens it) Profile
Unfolded phone (expanded, 2 panes)
Chat list + conversation Conversation + profile
Chat list + conversation Profile replaces the list (channel name brings it back)
Tablet (large, 3 panes)
Chat list + conversation Chat list + conversation + profile
Chat list + conversation Chat list + conversation + profile

Use Navigation 3's Material ListDetailSceneStrategy to display the chat list as the list pane, conversations as the detail pane, and profiles as the extra pane on wide windows, while keeping the existing ModalNavigationDrawer flow on single-pane phones.
  affordance.
- Provide per-channel conversation data from the fake data set instead
  of showing the #composers conversation in every channel.
- Give the list pane its own accessibility pane title instead of the
  private navigation_menu string of Compose UI, and let NavigationTest
  find the chat list in either the drawer or the list pane.
- Trim comments
@raystatic
raystatic requested a review from a team as a code owner October 22, 2026 06:45
@raystatic
raystatic requested a review from riggaroo October 22, 2026 06:45
@raystatic
raystatic marked this pull request as draft October 22, 2026 06:45
@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

No Jetpack Compose performance issues were found in the changed code.

Summary of Analyzed Changes

  • JetchatApp.kt:
    • directive calculation is properly memoized with explicit calculation keys using remember(windowAdaptiveInfo).
    • Coroutine effect LaunchedEffect(isMultiPane) correctly wraps suspending operation drawerState.close().
    • selectedKey = backStack.last() is cached in a local variable, avoiding duplicate snapshot state reads.
    • Scene strategies and adaptive pane layout decorators adhere to Navigation 3 and Material 3 Adaptive best practices.
  • Conversation.kt:
    • Direct condition check uiState.messages.isEmpty() avoids unnecessary derivedStateOf overhead for collection emptiness inspection.
  • JetchatAppBar.kt & JetchatScaffold.kt:
    • Nullability check if (onNavIconPressed != null) guards composable instantiation cleanly.
  • ConversationViewModel.kt, UserInput.kt, FakeData.kt, JetchatNavKeys.kt, NavigationTest.kt:
    • Non-composable configurations, data models, and test updates with no performance impact on Compose frame phases.

… 3-pane

windows), label it "Show chat list", and fix NavigationTest on wide windows.
@raystatic
raystatic marked this pull request as ready for review October 22, 2026 10:45
@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

Finding 1: Uncached Snapshot Collection Filtering and Loss of Instance Equality for Skippability

  • File: Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt
  • Lines: 106–114
  • Category: Category 1: State, snapshots, and stability & Category 5: Effects, lifecycle, and threading
  • Impact:
    1. backStack is a mutable snapshot collection (SnapshotStateList). In single-pane window mode (!isMultiPane), backStack.filter { it !is ChatList } executes directly within the body of JetchatApp on every recomposition pass (e.g., whenever drawerState.isOpen changes or during drawer swipe gestures). This causes repetitive collection iterations and allocates a new ArrayList on each recomposition.
    2. Because a new List instance is produced on every recomposition (displayBackStack !== previousDisplayBackStack), passing it downstream to NavDisplay(backStack = displayBackStack, ...) breaks the === instance equality check for unstable/collection parameters under Strong Skipping Mode. This prevents NavDisplay from skipping recomposition when JetchatApp recomposes for unrelated state changes.
  • Recommendation:
    Wrap the derivation in remember(isMultiPane) { derivedStateOf { ... } }. derivedStateOf tracks mutations to backStack and caches the resulting list instance across recompositions of JetchatApp, preserving instance equality (===) and avoiding unnecessary allocations:
    // 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 by remember(isMultiPane) {
        derivedStateOf {
            if (isMultiPane) {
                backStack
            } else {
                backStack.filter { it !is ChatList }.also {
                    check(it.isNotEmpty()) { "A conversation must always be on the back stack" }
                }
            }
        }
    }

Finding 2: Shape Re-allocation in Composition & Redundant Clipping Layer on Surface

  • File: Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt
  • Lines: 374–387
  • Category: Category 2: Layouts and measurement optimization & Category 5: Effects, lifecycle, and threading
  • Impact:
    1. val shape = RoundedCornerShape(20.dp) is allocated directly inside the title composable slot of ChannelNameBar. Because ChannelNameBar is a top app bar linked to scrollBehavior, scrolling the conversation triggers recompositions of the app bar, re-allocating a new RoundedCornerShape instance on every scroll frame.
    2. Applying Modifier.clip(shape) to Surface(shape = shape, ...) introduces a redundant clipping graphics layer node into the layout hierarchy. Surface already handles shape clipping and background shaping internally using its shape parameter.
  • Recommendation:
    Hoist RoundedCornerShape(20.dp) to a top-level constant outside the composable, and remove the redundant Modifier.clip(shape) from the modifier chain passed to Surface:
private val ChannelNameBarShape = RoundedCornerShape(20.dp)
        title = {
            Surface(
                modifier = if (onNavIconPressed != null) {
                    Modifier
                        .clickable(onClick = onNavIconPressed)
                        .semantics { contentDescription = navIconContentDescription }
                } else {
                    Modifier
                },
                shape = ChannelNameBarShape,
                color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
            ) {

Finding 3: Redundant Layout Wrapper Node in Placeholder

  • File: Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt
  • Lines: 231–245
  • Category: Category 2: Layouts and measurement optimization
  • Impact:
    ChatPlaceholder nests a Box(contentAlignment = Alignment.Center) inside an outer Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer). Surface introduces an extra layout node solely to apply the background color and fill size. Wrapping layout nodes increases layout tree depth and measurement/placement overhead.
  • Recommendation:
    Collapse the nested Surface + Box hierarchy into a single Box with Modifier.background:
/** Shown in the detail pane when no conversation is selected. */
@Composable
private fun ChatPlaceholder() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.surfaceContainer),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = stringResource(R.string.select_a_chat),
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = Modifier.padding(24.dp),
        )
    }
}

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

contentDescription = navDrawerDescription
modifier = if (onNavIconPressed != null) {
Modifier
.clip(shape)

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.

Does this need to be done twice?

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?

@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

No Jetpack Compose performance issues were found in the changed code.

Audit Summary

  • State, Snapshots & Stability: State derivations and pane scaffold transition states (targetState) are read appropriately in the relevant scopes. windowAdaptiveInfo is explicitly passed as a key to remember when computing scaffold directives. Repeated reads of backStack.last() are hoisted cleanly into a local variable. No collection models were annotated with unnecessary stability markers.
  • Layouts & Measurements: No onGloballyPositioned observers, layout-to-composition feedback loops, or redundant wrapper layouts were introduced.
  • Lazy Layouts: Direct list size and emptiness queries (uiState.messages.isEmpty()) access standard properties without redundant derivedStateOf wrappers. Existing item keys and content types are preserved.
  • Effects & Lifecycle: LaunchedEffect(isMultiPane) wraps suspending work (drawerState.close()) guarded by conditional checks without cycling effect keys during animations or scrolls.
  • Graphics & Custom Drawing: No un-cached drawing allocations, canvas nodes, or unoptimized image decoders were introduced in the diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants