fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 (#4631)
* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 * Fix autofill deprecations * Adapt our custom BottomSheetState and scaffold to the new APIs * Get rid of all the custom bottom sheet implementation It doesn't seem to be needed anymore 🎉 * Replace `semantics { invisibleToUser() }` with `hideFromAccessibility()` * Update screenshots * Add commit and cancel callbacks for autofill on the login view * Fix broken tests caused mainly by https://issuetracker.google.com/issues/366255137 Add `LocalUiTestMode` composition local and helper functions. * Remove dependency that caused a new license to need to be approved * Let setSafeContent handle setting the value for LocalUiTestMode * Fix broken test * Apply fix to RoomMemberModerationViewTest and RoomListDeclineInviteMenuTest --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jorge Martín <jorgem@element.io> Co-authored-by: ElementBot <android@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
parent
b59ddeb652
commit
7bb1e24ff5
37 changed files with 189 additions and 1012 deletions
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.modifiers
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalAutofill
|
||||
import androidx.compose.ui.platform.LocalAutofillTree
|
||||
|
||||
@Suppress("ModifierComposed")
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun Modifier.autofill(autofillTypes: List<AutofillType>, onFill: (String) -> Unit) = composed {
|
||||
val autofillNode = AutofillNode(autofillTypes, onFill = onFill)
|
||||
LocalAutofillTree.current += autofillNode
|
||||
|
||||
val autofill = LocalAutofill.current
|
||||
|
||||
this
|
||||
.onGloballyPositioned {
|
||||
// Inform autofill framework of where our composable is so it can show the popup in the right place
|
||||
autofillNode.boundingBox = it.boundsInWindow()
|
||||
}
|
||||
.onFocusChanged {
|
||||
autofill?.run {
|
||||
if (it.isFocused) {
|
||||
requestAutofillForNode(autofillNode)
|
||||
} else {
|
||||
cancelAutofillForNode(autofillNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,19 +10,18 @@ package io.element.android.libraries.designsystem.theme.components
|
|||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.BottomSheetScaffoldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.BottomSheetScaffoldState
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomBottomSheetScaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
|
|
@ -44,7 +43,7 @@ fun BottomSheetScaffold(
|
|||
contentColor: Color = contentColorFor(containerColor),
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
CustomBottomSheetScaffold(
|
||||
androidx.compose.material3.BottomSheetScaffold(
|
||||
sheetContent = sheetContent,
|
||||
modifier = modifier,
|
||||
scaffoldState = scaffoldState,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -33,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -52,11 +54,14 @@ fun ModalBottomSheet(
|
|||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
|
||||
// If we're running in UI test mode, we want to use a different shape to avoid
|
||||
// this issue: https://issuetracker.google.com/issues/366255137
|
||||
val safeShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else shape
|
||||
androidx.compose.material3.ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
sheetState = safeSheetState,
|
||||
shape = shape,
|
||||
shape = safeShape,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = tonalElevation,
|
||||
|
|
|
|||
|
|
@ -1,516 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
@file:OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
package io.element.android.libraries.designsystem.theme.components.bottomsheet
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.SheetValue.Expanded
|
||||
import androidx.compose.material3.SheetValue.PartiallyExpanded
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.collapse
|
||||
import androidx.compose.ui.semantics.dismiss
|
||||
import androidx.compose.ui.semantics.expand
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// These are needed until https://issuetracker.google.com/issues/306464779 is fixed
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun CustomBottomSheetScaffold(
|
||||
sheetContent: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
|
||||
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
|
||||
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
|
||||
sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
|
||||
sheetContainerColor: Color = Color.White,
|
||||
sheetContentColor: Color = contentColorFor(sheetContainerColor),
|
||||
sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
|
||||
sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
|
||||
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||
sheetSwipeEnabled: Boolean = true,
|
||||
topBar: @Composable (() -> Unit)? = null,
|
||||
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
|
||||
containerColor: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(containerColor),
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val peekHeightPx = with(LocalDensity.current) {
|
||||
sheetPeekHeight.roundToPx()
|
||||
}
|
||||
CustomBottomSheetScaffoldLayout(
|
||||
modifier = modifier,
|
||||
topBar = topBar,
|
||||
body = content,
|
||||
snackbarHost = {
|
||||
snackbarHost(scaffoldState.snackbarHostState)
|
||||
},
|
||||
sheetPeekHeight = sheetPeekHeight,
|
||||
sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
|
||||
sheetState = scaffoldState.bottomSheetState,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
bottomSheet = { layoutHeight ->
|
||||
CustomStandardBottomSheet(
|
||||
state = scaffoldState.bottomSheetState,
|
||||
peekHeight = sheetPeekHeight,
|
||||
sheetMaxWidth = sheetMaxWidth,
|
||||
sheetSwipeEnabled = sheetSwipeEnabled,
|
||||
calculateAnchors = { sheetSize ->
|
||||
val sheetHeight = sheetSize.height
|
||||
io.element.android.libraries.designsystem.theme.components.bottomsheet.DraggableAnchors {
|
||||
if (!scaffoldState.bottomSheetState.skipPartiallyExpanded) {
|
||||
PartiallyExpanded at (layoutHeight - peekHeightPx).toFloat()
|
||||
}
|
||||
if (sheetHeight != peekHeightPx) {
|
||||
Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat()
|
||||
}
|
||||
if (!scaffoldState.bottomSheetState.skipHiddenState) {
|
||||
SheetValue.Hidden at layoutHeight.toFloat()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = sheetShape,
|
||||
containerColor = sheetContainerColor,
|
||||
contentColor = sheetContentColor,
|
||||
tonalElevation = sheetTonalElevation,
|
||||
shadowElevation = sheetShadowElevation,
|
||||
dragHandle = sheetDragHandle,
|
||||
content = sheetContent
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressWarnings("ModifierWithoutDefault")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CustomBottomSheetScaffoldLayout(
|
||||
modifier: Modifier,
|
||||
topBar: @Composable (() -> Unit)?,
|
||||
body: @Composable (innerPadding: PaddingValues) -> Unit,
|
||||
bottomSheet: @Composable (layoutHeight: Int) -> Unit,
|
||||
snackbarHost: @Composable () -> Unit,
|
||||
sheetPeekHeight: Dp,
|
||||
sheetOffset: () -> Float,
|
||||
sheetState: CustomSheetState,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
) {
|
||||
// b/291735717 Remove this once deprecated methods without density are removed
|
||||
val density = LocalDensity.current
|
||||
SideEffect {
|
||||
sheetState.density = density
|
||||
}
|
||||
SubcomposeLayout { constraints ->
|
||||
val layoutWidth = constraints.maxWidth
|
||||
val layoutHeight = constraints.maxHeight
|
||||
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
|
||||
val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
|
||||
bottomSheet(layoutHeight)
|
||||
}[0].measure(looseConstraints)
|
||||
|
||||
val topBarPlaceable = topBar?.let {
|
||||
subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
|
||||
.measure(looseConstraints)
|
||||
}
|
||||
val topBarHeight = topBarPlaceable?.height ?: 0
|
||||
|
||||
val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
|
||||
val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
) { body(PaddingValues(bottom = sheetPeekHeight)) }
|
||||
}[0].measure(bodyConstraints)
|
||||
|
||||
val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
|
||||
.measure(looseConstraints)
|
||||
|
||||
layout(layoutWidth, layoutHeight) {
|
||||
val sheetOffsetY = sheetOffset().roundToInt()
|
||||
val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
|
||||
|
||||
val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
|
||||
val snackbarOffsetY = when (sheetState.currentValue) {
|
||||
SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height
|
||||
SheetValue.Expanded, SheetValue.Hidden -> layoutHeight - snackbarPlaceable.height
|
||||
}
|
||||
|
||||
// Placement order is important for elevation
|
||||
bodyPlaceable.placeRelative(0, topBarHeight)
|
||||
topBarPlaceable?.placeRelative(0, 0)
|
||||
sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
|
||||
snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CustomStandardBottomSheet(
|
||||
state: CustomSheetState,
|
||||
@Suppress("PrimitiveInLambda")
|
||||
calculateAnchors: (sheetSize: IntSize) -> DraggableAnchors<SheetValue>,
|
||||
peekHeight: Dp,
|
||||
sheetMaxWidth: Dp,
|
||||
sheetSwipeEnabled: Boolean,
|
||||
shape: Shape,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
tonalElevation: Dp,
|
||||
shadowElevation: Dp,
|
||||
dragHandle: @Composable (() -> Unit)?,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val orientation = Orientation.Vertical
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(max = sheetMaxWidth)
|
||||
.fillMaxWidth()
|
||||
.requiredHeightIn(min = peekHeight)
|
||||
.apply {
|
||||
if (sheetSwipeEnabled) {
|
||||
nestedScroll(
|
||||
remember(state.anchoredDraggableState) {
|
||||
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState = state,
|
||||
orientation = orientation,
|
||||
onFling = { scope.launch { state.settle(it) } }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.anchoredDraggable(
|
||||
state = state.anchoredDraggableState,
|
||||
orientation = orientation,
|
||||
enabled = sheetSwipeEnabled
|
||||
)
|
||||
.onSizeChanged { layoutSize ->
|
||||
val newAnchors = calculateAnchors(layoutSize)
|
||||
val newTarget = when (state.anchoredDraggableState.targetValue) {
|
||||
SheetValue.Hidden, SheetValue.PartiallyExpanded -> SheetValue.PartiallyExpanded
|
||||
SheetValue.Expanded -> {
|
||||
if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.PartiallyExpanded
|
||||
}
|
||||
}
|
||||
state.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
|
||||
},
|
||||
shape = shape,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = tonalElevation,
|
||||
shadowElevation = shadowElevation,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (dragHandle != null) {
|
||||
val partialExpandActionLabel =
|
||||
"Partial Expand"
|
||||
val dismissActionLabel = "Dismiss"
|
||||
val expandActionLabel = "Expand"
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.semantics(mergeDescendants = true) {
|
||||
with(state) {
|
||||
// Provides semantics to interact with the bottomsheet if there is more
|
||||
// than one anchor to swipe to and swiping is enabled.
|
||||
if (anchoredDraggableState.anchors.size > 1 && sheetSwipeEnabled) {
|
||||
if (currentValue == SheetValue.PartiallyExpanded) {
|
||||
expand(expandActionLabel) {
|
||||
scope.launch { expand() }
|
||||
true
|
||||
}
|
||||
} else {
|
||||
collapse(partialExpandActionLabel) {
|
||||
scope.launch { partialExpand() }
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!state.skipHiddenState) {
|
||||
dismiss(dismissActionLabel) {
|
||||
scope.launch { hide() }
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
dragHandle()
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and
|
||||
* corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable
|
||||
* [DraggableAnchors] instance later on.
|
||||
*/
|
||||
@ExperimentalFoundationApi
|
||||
class DraggableAnchorsConfig<T> {
|
||||
internal val anchors = mutableMapOf<T, Float>()
|
||||
|
||||
/**
|
||||
* Set the anchor position for [this] anchor.
|
||||
*
|
||||
* @param position The anchor position.
|
||||
*/
|
||||
@Suppress("BuilderSetStyle")
|
||||
infix fun T.at(position: Float) {
|
||||
anchors[this] = position
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new [DraggableAnchors] instance using a builder function.
|
||||
*
|
||||
* @param T The type of the anchor values.
|
||||
* @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors
|
||||
* @return A new [DraggableAnchors] instance with the anchor positions set by the `builder`
|
||||
* function.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@ExperimentalMaterial3Api
|
||||
@SuppressWarnings("FunctionName")
|
||||
internal fun <T : Any> DraggableAnchors(
|
||||
builder: DraggableAnchorsConfig<T>.() -> Unit
|
||||
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
|
||||
|
||||
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
|
||||
override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN
|
||||
override fun hasAnchorFor(value: T) = anchors.containsKey(value)
|
||||
|
||||
override fun closestAnchor(position: Float): T? = anchors.minByOrNull {
|
||||
abs(position - it.value)
|
||||
}?.key
|
||||
|
||||
override fun closestAnchor(
|
||||
position: Float,
|
||||
searchUpwards: Boolean
|
||||
): T? {
|
||||
return anchors.minByOrNull { (_, anchor) ->
|
||||
val delta = if (searchUpwards) anchor - position else position - anchor
|
||||
if (delta < 0) Float.POSITIVE_INFINITY else delta
|
||||
}?.key
|
||||
}
|
||||
|
||||
override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN
|
||||
|
||||
override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN
|
||||
|
||||
override val size: Int
|
||||
get() = anchors.size
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MapDraggableAnchors<*>) return false
|
||||
|
||||
return anchors == other.anchors
|
||||
}
|
||||
|
||||
override fun forEach(block: (anchor: T, position: Float) -> Unit) {
|
||||
for (anchor in anchors) {
|
||||
block(anchor.key, anchor.value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode() = 31 * anchors.hashCode()
|
||||
|
||||
override fun toString() = "MapDraggableAnchors($anchors)"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@SuppressWarnings("FunctionName")
|
||||
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState: CustomSheetState,
|
||||
orientation: Orientation,
|
||||
onFling: (velocity: Float) -> Unit
|
||||
): NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.toFloat()
|
||||
return if (delta < 0 && source == NestedScrollSource.UserInput) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (source == NestedScrollSource.UserInput) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val toFling = available.toFloat()
|
||||
val currentOffset = sheetState.requireOffset()
|
||||
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
|
||||
return if (toFling < 0 && currentOffset > minAnchor) {
|
||||
onFling(toFling)
|
||||
// since we go to the anchor with tween settling, consume all for the best UX
|
||||
available
|
||||
} else {
|
||||
Velocity.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
onFling(available.toFloat())
|
||||
return available
|
||||
}
|
||||
|
||||
private fun Float.toOffset(): Offset = Offset(
|
||||
x = if (orientation == Orientation.Horizontal) this else 0f,
|
||||
y = if (orientation == Orientation.Vertical) this else 0f
|
||||
)
|
||||
|
||||
@JvmName("velocityToFloat")
|
||||
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
|
||||
|
||||
@JvmName("offsetToFloat")
|
||||
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the [BottomSheetScaffold] composable.
|
||||
*
|
||||
* @param bottomSheetState the state of the persistent bottom sheet
|
||||
* @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Stable
|
||||
@SuppressWarnings("UseDataClass")
|
||||
class BottomSheetScaffoldState(
|
||||
val bottomSheetState: CustomSheetState,
|
||||
val snackbarHostState: SnackbarHostState
|
||||
)
|
||||
|
||||
/**
|
||||
* Create and [remember] a [BottomSheetScaffoldState].
|
||||
*
|
||||
* @param bottomSheetState the state of the standard bottom sheet. See
|
||||
* [rememberStandardBottomSheetState]
|
||||
* @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun rememberBottomSheetScaffoldState(
|
||||
bottomSheetState: CustomSheetState = rememberStandardBottomSheetState(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
): BottomSheetScaffoldState {
|
||||
return remember(bottomSheetState, snackbarHostState) {
|
||||
BottomSheetScaffoldState(
|
||||
bottomSheetState = bottomSheetState,
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SheetState] for [BottomSheetScaffold].
|
||||
*
|
||||
* @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or
|
||||
* [Expanded] if [skipHiddenState] is true
|
||||
* @param confirmValueChange optional callback invoked to confirm or veto a pending state change
|
||||
* @param [skipHiddenState] whether Hidden state is skipped for [BottomSheetScaffold]
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun rememberStandardBottomSheetState(
|
||||
initialValue: SheetValue = PartiallyExpanded,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
skipHiddenState: Boolean = true,
|
||||
) = rememberSheetState(false, confirmValueChange, initialValue, skipHiddenState)
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
internal fun rememberSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
skipHiddenState: Boolean = false,
|
||||
): CustomSheetState {
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
skipPartiallyExpanded,
|
||||
confirmValueChange,
|
||||
saver = CustomSheetState.Saver(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density
|
||||
)
|
||||
) {
|
||||
CustomSheetState(
|
||||
skipPartiallyExpanded,
|
||||
density,
|
||||
initialValue,
|
||||
confirmValueChange,
|
||||
skipHiddenState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.theme.components.bottomsheet
|
||||
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.exponentialDecay
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.SheetValue.Expanded
|
||||
import androidx.compose.material3.SheetValue.Hidden
|
||||
import androidx.compose.material3.SheetValue.PartiallyExpanded
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
class CustomSheetState
|
||||
@Deprecated(
|
||||
message = "This constructor is deprecated. " +
|
||||
"Please use the constructor that provides a [Density]",
|
||||
replaceWith = ReplaceWith(
|
||||
"SheetState(" +
|
||||
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
|
||||
"confirmValueChange, skipHiddenState)"
|
||||
)
|
||||
)
|
||||
constructor(
|
||||
internal val skipPartiallyExpanded: Boolean,
|
||||
initialValue: SheetValue = Hidden,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
internal val skipHiddenState: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* State of a sheet composable, such as [ModalBottomSheet]
|
||||
*
|
||||
* Contains states relating to its swipe position as well as animations between state values.
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
||||
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
||||
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
||||
* interaction.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
||||
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
||||
* programmatically or by user interaction.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Suppress("Deprecation")
|
||||
constructor(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
density: Density,
|
||||
initialValue: SheetValue = Hidden,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
skipHiddenState: Boolean = false,
|
||||
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
|
||||
this.density = density
|
||||
}
|
||||
|
||||
init {
|
||||
if (skipPartiallyExpanded) {
|
||||
require(initialValue != PartiallyExpanded) {
|
||||
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
|
||||
"is set to true."
|
||||
}
|
||||
}
|
||||
if (skipHiddenState) {
|
||||
require(initialValue != Hidden) {
|
||||
"The initial value must not be set to Hidden if skipHiddenState is set to true."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
|
||||
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
|
||||
* was in before the swipe or animation started.
|
||||
*/
|
||||
|
||||
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target value of the bottom sheet state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the sheet would animate to if the
|
||||
* swipe finishes. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet is visible.
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = anchoredDraggableState.currentValue != Hidden
|
||||
|
||||
/**
|
||||
* Require the current offset (in pixels) of the bottom sheet.
|
||||
*
|
||||
* The offset will be initialized during the first measurement phase of the provided sheet
|
||||
* content.
|
||||
*
|
||||
* These are the phases:
|
||||
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
|
||||
*
|
||||
* During the first composition, an [IllegalStateException] is thrown. In subsequent
|
||||
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
|
||||
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
|
||||
* frame, after layout.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
fun getOffset(): Float? = anchoredDraggableState.offset.takeIf { !it.isNaN() }
|
||||
|
||||
/**
|
||||
* Whether the sheet has an expanded state defined.
|
||||
*/
|
||||
|
||||
val hasExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet has a partially expanded state defined.
|
||||
*/
|
||||
val hasPartiallyExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
|
||||
|
||||
/**
|
||||
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
|
||||
* animation has been cancelled.
|
||||
* *
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun expand() {
|
||||
anchoredDraggableState.animateTo(Expanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
|
||||
* cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
|
||||
*/
|
||||
suspend fun partialExpand() {
|
||||
check(!skipPartiallyExpanded) {
|
||||
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
|
||||
" skipPartiallyExpanded to false to use this function."
|
||||
}
|
||||
animateTo(PartiallyExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
|
||||
* else [Expanded].
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun show() {
|
||||
val targetValue = when {
|
||||
hasPartiallyExpandedState -> PartiallyExpanded
|
||||
else -> Expanded
|
||||
}
|
||||
animateTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
|
||||
* been cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun hide() {
|
||||
check(!skipHiddenState) {
|
||||
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
|
||||
" to false to use this function."
|
||||
}
|
||||
animateTo(Hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate to a [targetValue].
|
||||
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
|
||||
* [targetValue] without updating the offset.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal suspend fun animateTo(
|
||||
targetValue: SheetValue,
|
||||
) {
|
||||
anchoredDraggableState.animateTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap to a [targetValue] without any animation.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal suspend fun snapTo(targetValue: SheetValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest anchor taking into account the velocity and settle at it with an animation.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal suspend fun settle(velocity: Float) {
|
||||
anchoredDraggableState.settle(velocity)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal var anchoredDraggableState = androidx.compose.foundation.gestures.AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
|
||||
decayAnimationSpec = AnchoredDraggableDefaults.DecayAnimationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
|
||||
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal val offset: Float? get() = anchoredDraggableState.offset
|
||||
|
||||
internal var density: Density? = null
|
||||
private fun requireDensity() = requireNotNull(density) {
|
||||
"SheetState did not have a density attached. Are you using SheetState with " +
|
||||
"BottomSheetScaffold or ModalBottomSheet component?"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState].
|
||||
*/
|
||||
@SuppressWarnings("FunctionName")
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean,
|
||||
density: Density
|
||||
) = Saver<CustomSheetState, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
CustomSheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "This function is deprecated. Please use the overload where Density is" +
|
||||
" provided.",
|
||||
replaceWith = ReplaceWith(
|
||||
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)"
|
||||
)
|
||||
)
|
||||
@Suppress("Deprecation", "FunctionName")
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean
|
||||
) = Saver<CustomSheetState, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
CustomSheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
internal object AnchoredDraggableDefaults {
|
||||
/**
|
||||
* The default animation used by [AnchoredDraggableState].
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
val SnapAnimationSpec = SpringSpec<Float>()
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
val DecayAnimationSpec = exponentialDecay<Float>()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.utils
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/**
|
||||
* A composition local that indicates whether the app is running in UI test mode.
|
||||
*/
|
||||
val LocalUiTestMode = staticCompositionLocalOf { false }
|
||||
Loading…
Add table
Add a link
Reference in a new issue