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: 2 additions & 0 deletions CanonicalLayouts/list-detail-compose/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20"
}

Expand Down Expand Up @@ -77,6 +78,7 @@ dependencies {
implementation "androidx.compose.ui:ui:1.9.1"
implementation "androidx.compose.ui:ui-tooling-preview"
implementation "androidx.window:window:1.4.0"
implementation "androidx.compose.material:material-icons-extended"
implementation 'androidx.compose.material3:material3:1.4.0-rc01'
implementation 'androidx.compose.material3.adaptive:adaptive:1.2.0-beta02'
implementation 'androidx.compose.material3.adaptive:adaptive-layout:1.2.0-beta02'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,15 @@
package com.example.listdetailcompose.ui

import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
Expand All @@ -42,32 +40,30 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDragHandle
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
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.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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.listdetailcompose.R
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize

// Create some simple sample data
private val loremIpsum = """
Expand All @@ -86,87 +82,60 @@ private val sampleWords = listOf(
"Honeydew" to R.drawable.ic_no_food,
).map { (word, icon) -> DefinedWord(word, icon) }

private data class DefinedWord(
@Parcelize
data class DefinedWord(
val word: String,
@DrawableRes val icon: Int,
val definition: String = loremIpsum
)
) : Parcelable

@SuppressLint("UnusedContentLambdaTargetStateParameter")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ListDetailSample() {
var selectedWordIndex: Int? by rememberSaveable { mutableStateOf(null) }
val navigator = rememberListDetailPaneScaffoldNavigator<Nothing>()
val navigator = rememberListDetailPaneScaffoldNavigator<DefinedWord>()
val scope = rememberCoroutineScope()
val isListAndDetailVisible =
navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded && navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded

BackHandler(enabled = navigator.canNavigateBack()) {
scope.launch {
navigator.navigateBack()
}
}

SharedTransitionLayout {
AnimatedContent(targetState = isListAndDetailVisible, label = "simple sample") {
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
val currentSelectedWordIndex = selectedWordIndex
val isDetailVisible =
navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
AnimatedPane {
ListContent(
words = sampleWords,
selectionState = if (isDetailVisible && currentSelectedWordIndex != null) {
SelectionVisibilityState.ShowSelection(currentSelectedWordIndex)
} else {
SelectionVisibilityState.NoSelection
},
onIndexClick = { index ->
selectedWordIndex = index
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
},
isListAndDetailVisible = isListAndDetailVisible,
isListVisible = !isDetailVisible,
animatedVisibilityScope = this@AnimatedPane,
sharedTransitionScope = this@SharedTransitionLayout
)
}
},
detailPane = {
val definedWord = selectedWordIndex?.let(sampleWords::get)
val isDetailVisible =
navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
AnimatedPane {
DetailContent(
definedWord = definedWord,
isListAndDetailVisible = isListAndDetailVisible,
isDetailVisible = isDetailVisible,
animatedVisibilityScope = this@AnimatedPane,
sharedTransitionScope = this@SharedTransitionLayout
)
}
},
paneExpansionState = rememberPaneExpansionState(navigator.scaffoldValue),
paneExpansionDragHandle = { state ->
val interactionSource =
remember { MutableInteractionSource() }
VerticalDragHandle(
modifier =
Modifier.paneExpansionDraggable(
state,
LocalMinimumInteractiveComponentSize.current,
interactionSource
), interactionSource = interactionSource
// [START android_adaptive_list_detail_pane_scaffold_compose_full]
NavigableListDetailPaneScaffold(
navigator = navigator,
listPane = {
AnimatedPane {
ListContent(
words = sampleWords,
selectionState = navigator.currentDestination?.contentKey?.let {
SelectionVisibilityState.ShowSelection(it)
} ?: SelectionVisibilityState.NoSelection,
onWordClick = { word ->
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, word)
}
},
animatedVisibilityScope = this@AnimatedPane,
sharedTransitionScope = this@SharedTransitionLayout
)
}
)
}
},
detailPane = {
AnimatedPane {
DetailContent(
definedWord = navigator.currentDestination?.contentKey,
animatedVisibilityScope = this@AnimatedPane,
sharedTransitionScope = this@SharedTransitionLayout,
onClosePane = {
scope.launch {
navigator.navigateBack(
backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
)

}
}
)
}
}
// [END android_adaptive_list_detail_pane_scaffold_compose_full]
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we should have this as a snippet here. The purpose of a snippet is to showcase a single, short API, in here we're showcasing too many things:

  • List-Detail
  • Animation
  • Navigation
  • State retention

I believe we should keep it as a complete sample here and move the 4 smaller snippets to the snippet repo

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will remove the region tags, sure.

Which "4 smaller snippets" are you referring to?

)
}
}

Expand All @@ -185,9 +154,9 @@ sealed interface SelectionVisibilityState {
*/
data class ShowSelection(
/**
* The index of the word that is selected.
* The word that is selected.
*/
val selectedWordIndex: Int
val selectedWord: DefinedWord
) : SelectionVisibilityState
}

Expand All @@ -198,10 +167,8 @@ sealed interface SelectionVisibilityState {
private fun ListContent(
words: List<DefinedWord>,
selectionState: SelectionVisibilityState,
onIndexClick: (index: Int) -> Unit,
onWordClick: (word: DefinedWord) -> Unit,
modifier: Modifier = Modifier,
isListAndDetailVisible: Boolean,
isListVisible: Boolean,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
Expand All @@ -221,22 +188,22 @@ private fun ListContent(
val interactionModifier = when (selectionState) {
SelectionVisibilityState.NoSelection -> {
Modifier.clickable(
onClick = { onIndexClick(index) }
onClick = { onWordClick(words[index]) }
)
}

is SelectionVisibilityState.ShowSelection -> {
Modifier.selectable(
selected = index == selectionState.selectedWordIndex,
onClick = { onIndexClick(index) }
selected = words[index] == selectionState.selectedWord,
onClick = { onWordClick(words[index]) }
)
}
}
Comment on lines 188 to 201
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The itemsIndexed lambda provides the word object directly. You can use it here instead of accessing it from the words list by index (words[index]). This improves readability and avoids an unnecessary list lookup.

Suggested change
val interactionModifier = when (selectionState) {
SelectionVisibilityState.NoSelection -> {
Modifier.clickable(
onClick = { onIndexClick(index) }
onClick = { onWordClick(words[index]) }
)
}
is SelectionVisibilityState.ShowSelection -> {
Modifier.selectable(
selected = index == selectionState.selectedWordIndex,
onClick = { onIndexClick(index) }
selected = words[index] == selectionState.selectedWord,
onClick = { onWordClick(words[index]) }
)
}
}
val interactionModifier = when (selectionState) {
SelectionVisibilityState.NoSelection -> {
Modifier.clickable(
onClick = { onWordClick(word) }
)
}
is SelectionVisibilityState.ShowSelection -> {
Modifier.selectable(
selected = word == selectionState.selectedWord,
onClick = { onWordClick(word) }
)
}
}


val containerColor = when (selectionState) {
SelectionVisibilityState.NoSelection -> MaterialTheme.colorScheme.surface
is SelectionVisibilityState.ShowSelection ->
if (index == selectionState.selectedWordIndex) {
if (words[index] == selectionState.selectedWord) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
Expand All @@ -249,7 +216,7 @@ private fun ListContent(
)

is SelectionVisibilityState.ShowSelection ->
if (index == selectionState.selectedWordIndex) {
if (words[index] == selectionState.selectedWord) {
null
} else {
BorderStroke(
Expand All @@ -268,24 +235,18 @@ private fun ListContent(
.fillMaxWidth()
) {
Row {
val imageModifier = Modifier.padding(horizontal = 8.dp)
if (!isListAndDetailVisible && isListVisible) {
with(sharedTransitionScope) {
val state = rememberSharedContentState(key = word.word)
imageModifier.then(
Modifier.sharedElement(
state,
animatedVisibilityScope = animatedVisibilityScope
with(sharedTransitionScope) {
Image(
painter = painterResource(id = word.icon),
contentDescription = word.word,
modifier = Modifier
.padding(horizontal = 8.dp)
.sharedElement(
rememberSharedContentState(key = "image-${word.word}"),
animatedVisibilityScope = animatedVisibilityScope,
)
)
}
)
}

Image(
painter = painterResource(id = word.icon),
contentDescription = word.word,
modifier = imageModifier
)
Text(
text = word.word,
modifier = Modifier
Expand All @@ -302,43 +263,42 @@ private fun ListContent(
/**
* The content for the detail pane.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun DetailContent(
definedWord: DefinedWord?,
modifier: Modifier = Modifier,
isListAndDetailVisible: Boolean,
isDetailVisible: Boolean,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
animatedVisibilityScope: AnimatedVisibilityScope,
onClosePane: () -> Unit
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp)
) {
if (definedWord != null) {
// Allow users to dismiss the detail pane. Use back navigation to
// hide an expanded detail pane.
IconButton(
modifier = Modifier.align(Alignment.End).padding(16.dp),
onClick = onClosePane
) {
Icon(Icons.Default.Close, contentDescription = "Close")
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For better accessibility and to support internationalization, it's recommended to use string resources for UI text. Please extract "Close" to a string resource in strings.xml (e.g., <string name="close">Close</string>) and reference it here using stringResource().

Suggested change
Icon(Icons.Default.Close, contentDescription = "Close")
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close))

}

val imageModifier = Modifier
.padding(horizontal = 8.dp)
.then(
if (!isListAndDetailVisible && isDetailVisible) {
with(sharedTransitionScope) {
val state = rememberSharedContentState(key = definedWord.word)
Modifier.sharedElement(
state,
animatedVisibilityScope = animatedVisibilityScope
)
}
} else {
Modifier
}
with(sharedTransitionScope) {
Image(
painter = painterResource(id = definedWord.icon),
contentDescription = definedWord.word,
modifier = Modifier
.padding(horizontal = 8.dp)
.sharedElement(
rememberSharedContentState(key = "image-${definedWord.word}"),
animatedVisibilityScope = animatedVisibilityScope,
)
)

Image(
painter = painterResource(id = definedWord.icon),
contentDescription = definedWord.word,
modifier = imageModifier
)
}
Text(
text = definedWord.word,
style = MaterialTheme.typography.headlineMedium
Expand Down