[go: nahoru, domu]

Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add expandable pane to list-detail-compose #453

Merged
merged 3 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add expandable pane to list-detail-compose
  • Loading branch information
alexvanyo committed Jan 18, 2024
commit 343938474033c7b5463a03f99063f5e130a75b53
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -59,7 +102,6 @@ fun ListDetail(
detailKey: Any?,
list: @Composable (isDetailVisible: Boolean) -> Unit,
detail: @Composable (isListVisible: Boolean) -> Unit,
twoPaneStrategy: TwoPaneStrategy,
displayFeatures: List<DisplayFeature>,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -122,35 +164,251 @@ 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,
>
role = null,
>
)
// 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 {
end()
}
}
}

enum class ExpandablePaneState {
ListOnly, ListAndDetail, DetailOnly
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,6 @@ fun ListDetailSample(
}
)
},
twoPaneStrategy = HorizontalTwoPaneStrategy(
splitFraction = 1f / 3f,
),
displayFeatures = displayFeatures,
modifier = Modifier.padding(horizontal = 16.dp)
)
Expand Down