From 114ad7cafd53055ad5e61936630644fbd5791b71 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Thu, 11 Jan 2024 17:55:15 -0800 Subject: [PATCH 1/3] Update versions --- .../list-detail-compose/app/build.gradle | 23 ++++++++++--------- .../list-detail-compose/build.gradle | 6 ++--- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CanonicalLayouts/list-detail-compose/app/build.gradle b/CanonicalLayouts/list-detail-compose/app/build.gradle index 102b51ecb..071e514a2 100644 --- a/CanonicalLayouts/list-detail-compose/app/build.gradle +++ b/CanonicalLayouts/list-detail-compose/app/build.gradle @@ -20,12 +20,12 @@ plugins { android { namespace 'com.example.listdetailcompose' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.example.listdetailcompose" minSdk 21 - targetSdk 33 + targetSdk 34 versionCode 1 versionName "1.0" @@ -52,7 +52,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.3.2' + kotlinCompilerExtensionVersion '1.5.8' } packagingOptions { resources { @@ -62,17 +62,18 @@ android { } dependencies { - def composeBom = platform('androidx.compose:compose-bom:2022.10.00') + def composeBom = platform('androidx.compose:compose-bom:2023.10.01') implementation(composeBom) - implementation "com.google.accompanist:accompanist-adaptive:0.27.0" - implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' - implementation 'androidx.activity:activity-compose:1.6.1' - implementation "androidx.compose.ui:ui" + implementation "com.google.accompanist:accompanist-adaptive:0.32.0" + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation "androidx.compose.foundation:foundation:1.6.0-rc01" + implementation "androidx.compose.ui:ui:1.6.0-rc01" implementation "androidx.compose.ui:ui-tooling-preview" - implementation "androidx.window:window:1.1.0-alpha03" + implementation "androidx.window:window:1.2.0" implementation 'androidx.compose.material3:material3' implementation "androidx.compose.material3:material3-window-size-class" testImplementation 'junit:junit:4.13.2' -} \ No newline at end of file +} diff --git a/CanonicalLayouts/list-detail-compose/build.gradle b/CanonicalLayouts/list-detail-compose/build.gradle index b5e548874..b15d04daa 100644 --- a/CanonicalLayouts/list-detail-compose/build.gradle +++ b/CanonicalLayouts/list-detail-compose/build.gradle @@ -14,7 +14,7 @@ * limitations under the License. */ plugins { - id 'com.android.application' version '7.3.1' apply false - id 'com.android.library' version '7.3.1' apply false - id 'org.jetbrains.kotlin.android' version '1.7.20' apply false + id 'com.android.application' version '8.2.1' apply false + id 'com.android.library' version '8.2.1' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false } diff --git a/CanonicalLayouts/list-detail-compose/gradle/wrapper/gradle-wrapper.properties b/CanonicalLayouts/list-detail-compose/gradle/wrapper/gradle-wrapper.properties index a95e68993..b63e47b36 100644 --- a/CanonicalLayouts/list-detail-compose/gradle/wrapper/gradle-wrapper.properties +++ b/CanonicalLayouts/list-detail-compose/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed May 25 14:11:15 UTC 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 343938474033c7b5463a03f99063f5e130a75b53 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Thu, 11 Jan 2024 17:55:30 -0800 Subject: [PATCH 2/3] Add expandable pane to list-detail-compose --- .../listdetailcompose/ui/ListDetail.kt | 284 +++++++++++++++++- .../listdetailcompose/ui/ListDetailSample.kt | 3 - 2 files changed, 271 insertions(+), 16 deletions(-) diff --git a/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt b/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt index 29560586b..c9029f1a1 100644 --- a/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt +++ b/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt @@ -17,20 +17,63 @@ package com.example.listdetailcompose.ui import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.window.layout.DisplayFeature import com.google.accompanist.adaptive.FoldAwareConfiguration +import com.google.accompanist.adaptive.SplitResult import com.google.accompanist.adaptive.TwoPane -import com.google.accompanist.adaptive.TwoPaneStrategy +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.roundToInt /** * A higher-order component displaying an opinionated list-detail format. @@ -43,14 +86,14 @@ import com.google.accompanist.adaptive.TwoPaneStrategy * shown (to reset instance state. * * When there is enough space to display both list and detail, pass `true` to [showListAndDetail] - * to show both the list and the detail at the same time. This content is displayed in a [TwoPane] - * with the given [twoPaneStrategy]. + * to show both the list and the detail at the same time. This content is displayed in a [TwoPane]. * * When there is not enough space to display both list and detail, which slot is displayed is based * on [isDetailOpen]. Internally, this state is changed in an opinionated way via [setIsDetailOpen]. * For instance, when showing just the detail screen, a back button press will call * [setIsDetailOpen] passing `false`. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun ListDetail( isDetailOpen: Boolean, @@ -59,7 +102,6 @@ fun ListDetail( detailKey: Any?, list: @Composable (isDetailVisible: Boolean) -> Unit, detail: @Composable (isListVisible: Boolean) -> Unit, - twoPaneStrategy: TwoPaneStrategy, displayFeatures: List, modifier: Modifier = Modifier, ) { @@ -122,31 +164,243 @@ fun ListDetail( detail(showList) } } + } + } + + val density = LocalDensity.current + val anchoredDraggableState = rememberSaveable( + saver = AnchoredDraggableState.Saver( + animationSpec = spring(), + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { with(density) { 400.dp.toPx() } }, + ) + ) { + AnchoredDraggableState( + initialValue = ExpandablePaneState.ListAndDetail, + animationSpec = spring(), + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { with(density) { 400.dp.toPx() } }, + ) + } + + val coroutineScope = rememberCoroutineScope() - // If showing just the detail, allow a back press to hide the detail to return to - // the list. - if (!showList) { - BackHandler { - setIsDetailOpen(false) + // Sync the `isDetailOpen` as a side-effect to the expandable pane state. + LaunchedEffect(isDetailOpen) { + if (isDetailOpen) { + when (anchoredDraggableState.currentValue) { + ExpandablePaneState.ListOnly -> { + anchoredDraggableState.animateTo(ExpandablePaneState.DetailOnly) + } + ExpandablePaneState.ListAndDetail, + ExpandablePaneState.DetailOnly + -> Unit + } + } else { + when (anchoredDraggableState.currentValue) { + ExpandablePaneState.ListOnly, + ExpandablePaneState.ListAndDetail -> Unit + ExpandablePaneState.DetailOnly -> { + anchoredDraggableState.animateTo(ExpandablePaneState.ListOnly) } } } } - Box(modifier = modifier) { + // Update the `isDetailOpen` boolean as a side-effect of the expandable pane reaching a specific value. + // We only do this if both the list and detail are capable of being shown, as + if (showListAndDetail) { + LaunchedEffect(anchoredDraggableState) { + snapshotFlow { anchoredDraggableState.currentValue } + .onEach { + when (anchoredDraggableState.currentValue) { + ExpandablePaneState.ListOnly -> setIsDetailOpen(false) + ExpandablePaneState.ListAndDetail -> setIsDetailOpen(true) + ExpandablePaneState.DetailOnly -> setIsDetailOpen(true) + } + } + .collect() + } + } + + // If showing just the detail due to the expandable pane state, allow a back press to hide the detail to return to + // the list. + BackHandler( + enabled = showListAndDetail && anchoredDraggableState.currentValue == ExpandablePaneState.DetailOnly + ) { + coroutineScope.launch { + anchoredDraggableState.animateTo(ExpandablePaneState.ListOnly) + } + } + + // If showing just the detail, allow a back press to hide the detail to return to + // the list. + BackHandler( + enabled = !showListAndDetail && !showList + ) { + setIsDetailOpen(false) + } + + val minListPaneWidth = 300.dp + val minDetailPaneWidth = 300.dp + + Box( + modifier = modifier.onSizeChanged { + anchoredDraggableState.updateAnchors( + newAnchors = DraggableAnchors { + ExpandablePaneState.ListOnly at it.width.toFloat() + ExpandablePaneState.ListAndDetail at it.width.toFloat() / 2f + ExpandablePaneState.DetailOnly at 0f + }, + // Keep the current target, even if resizing causes the offset to be closer to a different one + newTarget = anchoredDraggableState.targetValue + ) + } + ) { if (showList && showDetail) { TwoPane( first = { - start() + // Enforce the minimum list pane width, aligning to the start edge of the screen + // Modifier.requiredWidthIn(min = minListPaneWidth) doesn't work because the content + // would be centered in the available space + Box( + Modifier + .clipToBounds() + .layout { measurable, constraints -> + val width = max(minListPaneWidth.roundToPx(), constraints.maxWidth) + val placeable = measurable.measure( + constraints.copy( + minWidth = minListPaneWidth.roundToPx(), + maxWidth = width + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.placeRelative( + x = 0, + y = 0 + ) + } + } + ) { + start() + } }, second = { - end() + // Enforce the minimum detail pane width, aligning to the end edge of the screen + // Modifier.requiredWidthIn(min = minDetailPaneWidth) doesn't work because the content + // would be centered in the available space + Box( + Modifier + .clipToBounds() + .layout { measurable, constraints -> + val width = max(minDetailPaneWidth.roundToPx(), constraints.maxWidth) + val placeable = measurable.measure( + constraints.copy( + minWidth = minDetailPaneWidth.roundToPx(), + maxWidth = width + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.placeRelative( + x = constraints.maxWidth - max(constraints.maxWidth, placeable.width), + y = 0 + ) + } + } + ) { + end() + } + }, + strategy = { _, layoutDirection, layoutCoordinates -> + val xOffset = when (layoutDirection) { + LayoutDirection.Ltr -> anchoredDraggableState.offset + LayoutDirection.Rtl -> layoutCoordinates.size.width - anchoredDraggableState.offset + } + + SplitResult( + gapOrientation = Orientation.Vertical, + gapBounds = Rect( + offset = Offset(xOffset, 0f), + size = Size(0f, layoutCoordinates.size.height.toFloat()) + ) + ) }, - strategy = twoPaneStrategy, displayFeatures = displayFeatures, foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, modifier = Modifier.fillMaxSize(), ) + + val dragHandleInteractionSource = remember { MutableInteractionSource() } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterStart) + .size(64.dp) + // Offset back half the width so that we are positing the center of the handle + .offset(x = -32.dp) + .offset { + IntOffset( + anchoredDraggableState + .requireOffset() + .roundToInt(), + 0 + ) + } + .anchoredDraggable( + state = anchoredDraggableState, + reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, + orientation = Orientation.Horizontal, + interactionSource = dragHandleInteractionSource, + ) + .hoverable(dragHandleInteractionSource) + // TODO: Workaround for https://issuetracker.google.com/issues/319881002 to allow isPressed + // to be true + .clickable( + interactionSource = dragHandleInteractionSource, + indication = null, + onClickLabel = null, + role = null, + onClick = {}, + ) + // Allow the drag handle to override the system navigation gesture + .systemGestureExclusion() + ) { + val isHovered by dragHandleInteractionSource.collectIsHoveredAsState() + val isPressed by dragHandleInteractionSource.collectIsPressedAsState() + val isDragged by dragHandleInteractionSource.collectIsDraggedAsState() + val isActive = isHovered || isPressed || isDragged + + val width by animateDpAsState( + if (isActive) 12.dp else 4.dp, + label = "Drag Handle Width" + ) + val color by animateColorAsState( + if (isActive) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.outline + }, + label = "Drag Handle Color" + ) + + Canvas( + modifier = Modifier.fillMaxSize() + ) { + val height = 48.dp + val rectSize = DpSize(width, height).toSize() + + drawRoundRect( + color = color, + topLeft = Offset( + (size.width - rectSize.width) / 2, + (size.height - rectSize.height) / 2, + ), + size = rectSize, + cornerRadius = CornerRadius(rectSize.width / 2f), + ) + } + } } else if (showList) { start() } else { @@ -154,3 +408,7 @@ fun ListDetail( } } } + +enum class ExpandablePaneState { + ListOnly, ListAndDetail, DetailOnly +} diff --git a/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetailSample.kt b/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetailSample.kt index 1eb9a33d0..935db3722 100644 --- a/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetailSample.kt +++ b/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetailSample.kt @@ -135,9 +135,6 @@ fun ListDetailSample( } ) }, - twoPaneStrategy = HorizontalTwoPaneStrategy( - splitFraction = 1f / 3f, - ), displayFeatures = displayFeatures, modifier = Modifier.padding(horizontal = 16.dp) ) From 15e9ce1596c180b4589fd811e5b0465ee34dc320 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Tue, 16 Jan 2024 11:05:25 -0800 Subject: [PATCH 3/3] Replace onSizeChanged with layout --- .../com/example/listdetailcompose/ui/ListDetail.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt b/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt index c9029f1a1..e65636e73 100644 --- a/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt +++ b/CanonicalLayouts/list-detail-compose/app/src/main/java/com/example/listdetailcompose/ui/ListDetail.kt @@ -245,16 +245,22 @@ fun ListDetail( val minDetailPaneWidth = 300.dp Box( - modifier = modifier.onSizeChanged { + modifier = modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + anchoredDraggableState.updateAnchors( newAnchors = DraggableAnchors { - ExpandablePaneState.ListOnly at it.width.toFloat() - ExpandablePaneState.ListAndDetail at it.width.toFloat() / 2f + ExpandablePaneState.ListOnly at placeable.width.toFloat() + ExpandablePaneState.ListAndDetail at placeable.width.toFloat() / 2f ExpandablePaneState.DetailOnly at 0f }, // Keep the current target, even if resizing causes the offset to be closer to a different one newTarget = anchoredDraggableState.targetValue ) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } } ) { if (showList && showDetail) {