Improve menu dismissal

Copy M3 ModalBottomSheet to add 'skipPartiallyCollapsed' support 😕
pull/2584/head
Alex Baker 7 months ago
parent b5748aa8e6
commit 94a719cb66

@ -14,9 +14,13 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.LaunchedEffect
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.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@ -48,6 +52,9 @@ import org.tasks.billing.PurchaseActivity
import org.tasks.compose.collectAsStateLifecycleAware
import org.tasks.compose.drawer.DrawerAction
import org.tasks.compose.drawer.DrawerItem
import org.tasks.compose.drawer.ModalBottomSheet
import org.tasks.compose.drawer.SheetState
import org.tasks.compose.drawer.SheetValue
import org.tasks.compose.drawer.TaskListDrawer
import org.tasks.data.AlarmDao
import org.tasks.data.LocationDao
@ -122,9 +129,29 @@ class MainActivity : AppCompatActivity(), TaskListFragmentCallbackHandler {
val state = viewModel.state.collectAsStateLifecycleAware().value
if (state.drawerOpen) {
MdcTheme {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = preferences.isTopAppBar,
)
var expanded by remember { mutableStateOf(false) }
val skipPartiallyExpanded = remember(expanded) {
expanded || preferences.isTopAppBar
}
val sheetState = rememberSaveable(
skipPartiallyExpanded,
saver = SheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = { true },
)
) {
SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = if (skipPartiallyExpanded) SheetValue.Expanded else SheetValue.PartiallyExpanded,
confirmValueChange = { true },
skipHiddenState = false,
)
}
LaunchedEffect(sheetState.targetValue) {
if (sheetState.targetValue == SheetValue.Expanded) {
expanded = true
}
}
ModalBottomSheet(
sheetState = sheetState,
containerColor = MaterialTheme.colors.surface,

@ -0,0 +1,25 @@
// ktlint-disable filename
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.tasks.compose.drawer
/* Copy of androidx.compose.material.ActualJvm, mirrored from Foundation. This is used for the
M2/M3-internal copy of MutatorMutex.
*/
internal typealias InternalAtomicReference<V> =
java.util.concurrent.atomic.AtomicReference<V>

@ -0,0 +1,160 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.tasks.compose.drawer
import androidx.compose.foundation.MutatePriority
import androidx.compose.runtime.Stable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Mutual exclusion for UI state mutation over time.
*
* [mutate] permits interruptible state mutation over time using a standard [MutatePriority].
* A [InternalMutatorMutex] enforces that only a single writer can be active at a time for a particular
* state resource. Instead of queueing callers that would acquire the lock like a traditional
* [Mutex], new attempts to [mutate] the guarded state will either cancel the current mutator or
* if the current mutator has a higher priority, the new caller will throw [CancellationException].
*
* [InternalMutatorMutex] should be used for implementing hoisted state objects that many mutators may
* want to manipulate over time such that those mutators can coordinate with one another. The
* [InternalMutatorMutex] instance should be hidden as an implementation detail. For example:
*
*/
@Stable
internal class InternalMutatorMutex {
private class Mutator(val priority: MutatePriority, val job: Job) {
fun canInterrupt(other: Mutator) = priority >= other.priority
fun cancel() = job.cancel()
}
private val currentMutator = InternalAtomicReference<Mutator?>(null)
private val mutex = Mutex()
private fun tryMutateOrCancel(mutator: Mutator) {
while (true) {
val oldMutator = currentMutator.get()
if (oldMutator == null || mutator.canInterrupt(oldMutator)) {
if (currentMutator.compareAndSet(oldMutator, mutator)) {
oldMutator?.cancel()
break
}
} else throw CancellationException("Current mutation had a higher priority")
}
}
/**
* Enforce that only a single caller may be active at a time.
*
* If [mutate] is called while another call to [mutate] or [mutateWith] is in progress, their
* [priority] values are compared. If the new caller has a [priority] equal to or higher than
* the call in progress, the call in progress will be cancelled, throwing
* [CancellationException] and the new caller's [block] will be invoked. If the call in
* progress had a higher [priority] than the new caller, the new caller will throw
* [CancellationException] without invoking [block].
*
* @param priority the priority of this mutation; [MutatePriority.Default] by default.
* Higher priority mutations will interrupt lower priority mutations.
* @param block mutation code to run mutually exclusive with any other call to [mutate],
* [mutateWith] or [tryMutate].
*/
suspend fun <R> mutate(
priority: MutatePriority = MutatePriority.Default,
block: suspend () -> R
) = coroutineScope {
val mutator = Mutator(priority, coroutineContext[Job]!!)
tryMutateOrCancel(mutator)
mutex.withLock {
try {
block()
} finally {
currentMutator.compareAndSet(mutator, null)
}
}
}
/**
* Enforce that only a single caller may be active at a time.
*
* If [mutateWith] is called while another call to [mutate] or [mutateWith] is in progress,
* their [priority] values are compared. If the new caller has a [priority] equal to or
* higher than the call in progress, the call in progress will be cancelled, throwing
* [CancellationException] and the new caller's [block] will be invoked. If the call in
* progress had a higher [priority] than the new caller, the new caller will throw
* [CancellationException] without invoking [block].
*
* This variant of [mutate] calls its [block] with a [receiver], removing the need to create
* an additional capturing lambda to invoke it with a receiver object. This can be used to
* expose a mutable scope to the provided [block] while leaving the rest of the state object
* read-only. For example:
*
* @param receiver the receiver `this` that [block] will be called with
* @param priority the priority of this mutation; [MutatePriority.Default] by default.
* Higher priority mutations will interrupt lower priority mutations.
* @param block mutation code to run mutually exclusive with any other call to [mutate],
* [mutateWith] or [tryMutate].
*/
suspend fun <T, R> mutateWith(
receiver: T,
priority: MutatePriority = MutatePriority.Default,
block: suspend T.() -> R
) = coroutineScope {
val mutator = Mutator(priority, coroutineContext[Job]!!)
tryMutateOrCancel(mutator)
mutex.withLock {
try {
receiver.block()
} finally {
currentMutator.compareAndSet(mutator, null)
}
}
}
/**
* Attempt to mutate synchronously if there is no other active caller.
* If there is no other active caller, the [block] will be executed in a lock. If there is
* another active caller, this method will return false, indicating that the active caller
* needs to be cancelled through a [mutate] or [mutateWith] call with an equal or higher
* mutation priority.
*
* Calls to [mutate] and [mutateWith] will suspend until execution of the [block] has finished.
*
* @param block mutation code to run mutually exclusive with any other call to [mutate],
* [mutateWith] or [tryMutate].
* @return true if the [block] was executed, false if there was another active caller and the
* [block] was not executed.
*/
fun tryMutate(block: () -> Unit): Boolean {
val didLock = mutex.tryLock()
if (didLock) {
try {
block()
} finally {
mutex.unlock()
}
}
return didLock
}
}

@ -0,0 +1,534 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.tasks.compose.drawer
import android.content.Context
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
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.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.tasks.compose.drawer.SheetValue.Expanded
import org.tasks.compose.drawer.SheetValue.Hidden
import org.tasks.compose.drawer.SheetValue.PartiallyExpanded
import java.util.UUID
import kotlin.math.max
import kotlin.math.roundToInt
/**
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
content: @Composable ColumnScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.swipeableState.confirmValueChange(Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
// Callback that is invoked when the anchors have changed.
val anchorChangeHandler = remember(sheetState, scope) {
ModalBottomSheetAnchorChangeHandler(
state = sheetState,
animateTo = { target, velocity ->
scope.launch { sheetState.animateTo(target, velocity = velocity) }
},
snapTo = { target ->
val didSnapImmediately = sheetState.trySnapTo(target)
if (!didSnapImmediately) {
scope.launch { sheetState.snapTo(target) }
}
}
)
}
ModalBottomSheetPopup(
onDismissRequest = {
// if (sheetState.currentValue == Expanded && sheetState.hasPartiallyExpandedState) {
// scope.launch { sheetState.partialExpand() }
// } else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
// }
},
windowInsets = windowInsets,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != Hidden
)
Surface(
modifier = modifier
.widthIn(max = BottomSheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt()
)
}
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss
)
}
)
.modalBottomSheetSwipeable(
sheetState = sheetState,
anchorChangeHandler = anchorChangeHandler,
screenHeight = fullHeight.toFloat(),
onDragStopped = {
settleToDismiss(it)
},
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
Box(
Modifier
.align(Alignment.CenterHorizontally)
) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
@Deprecated(
message = "Use ModalBottomSheet overload with windowInset parameter.",
level = DeprecationLevel.HIDDEN
)
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
content: @Composable ColumnScope.() -> Unit,
) = ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = sheetState,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
content = content,
)
/**
* Create and [remember] a [SheetState] for [ModalBottomSheet].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, Hidden)
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet)
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetSwipeable(
sheetState: SheetState,
anchorChangeHandler: AnchorChangeHandler<SheetValue>,
screenHeight: Float,
onDragStopped: CoroutineScope.(velocity: Float) -> Unit,
) = draggable(
state = sheetState.swipeableState.swipeDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.swipeableState.isAnimationRunning,
onDragStopped = onDragStopped
)
.swipeAnchors(
state = sheetState.swipeableState,
anchorChangeHandler = anchorChangeHandler,
possibleValues = setOf(Hidden, PartiallyExpanded, Expanded),
) { value, sheetSize ->
when (value) {
Hidden -> screenHeight
PartiallyExpanded -> when {
sheetSize.height < screenHeight / 2 -> null
sheetState.skipPartiallyExpanded -> null
else -> screenHeight / 2f
}
Expanded -> if (sheetSize.height != 0) {
max(0f, screenHeight - sheetSize.height)
} else null
}
}
@ExperimentalMaterial3Api
private fun ModalBottomSheetAnchorChangeHandler(
state: SheetState,
animateTo: (target: SheetValue, velocity: Float) -> Unit,
snapTo: (target: SheetValue) -> Unit,
) = AnchorChangeHandler<SheetValue> { previousTarget, previousAnchors, newAnchors ->
val previousTargetOffset = previousAnchors[previousTarget]
val newTarget = when (previousTarget) {
Hidden -> Hidden
PartiallyExpanded, Expanded -> {
val hasPartiallyExpandedState = newAnchors.containsKey(PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded
else if (newAnchors.containsKey(Expanded)) Expanded else Hidden
newTarget
}
}
val newTargetOffset = newAnchors.getValue(newTarget)
if (newTargetOffset != previousTargetOffset) {
if (state.swipeableState.isAnimationRunning || previousAnchors.isEmpty()) {
// Re-target the animation to the new offset if it changed
animateTo(newTarget, state.swipeableState.lastVelocity)
} else {
// Snap to the new offset value of the target if no animation was running
snapTo(newTarget)
}
}
}
/**
* Popup specific for modal bottom sheet.
*/
@Composable
internal fun ModalBottomSheetPopup(
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val modalBottomSheetWindow = remember {
ModalBottomSheetWindow(
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.imePadding()
) {
currentContent()
}
}
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheet] */
private class ModalBottomSheetWindow(
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID,
) :
AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector {
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() {
val density = context.resources.displayMetrics.density
return (context.resources.configuration.screenWidthDp * density).roundToInt()
}
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
content()
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
/**
* Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK) {
if (keyDispatcherState == null) {
return super.dispatchKeyEvent(event)
}
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val state = keyDispatcherState
state?.startTracking(event, this)
return true
} else if (event.action == KeyEvent.ACTION_UP) {
val state = keyDispatcherState
if (state != null && state.isTracking(event) && !event.isCanceled) {
onDismissRequest()
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun onGlobalLayout() {
// No-op
}
}

@ -0,0 +1,427 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.tasks.compose.drawer
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
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.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import org.tasks.compose.drawer.SheetValue.Expanded
import org.tasks.compose.drawer.SheetValue.Hidden
import org.tasks.compose.drawer.SheetValue.PartiallyExpanded
/**
* State of a sheet composable, such as [ModalBottomSheet]
*
* Contains states relating to it's 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 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.
*/
@Stable
@ExperimentalMaterial3Api
class SheetState(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
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() = swipeableState.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() = swipeableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = swipeableState.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 = swipeableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = swipeableState.hasAnchorForValue(Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = swipeableState.hasAnchorForValue(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() {
swipeableState.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
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = swipeableState.lastVelocity
) {
swipeableState.animateTo(targetValue, velocity)
}
/**
* 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
*/
internal suspend fun snapTo(targetValue: SheetValue) {
swipeableState.snapTo(targetValue)
}
/**
* Attempt to snap synchronously. Snapping can happen synchronously when there is no other swipe
* transaction like a drag or an animation is progress. If there is another interaction in
* progress, the suspending [snapTo] overload needs to be used.
*
* @return true if the synchronous snap was successful, or false if we couldn't snap synchronous
*/
internal fun trySnapTo(targetValue: SheetValue) = swipeableState.trySnapTo(targetValue)
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
swipeableState.settle(velocity)
}
internal var swipeableState = SwipeableV2State(
initialValue = initialValue,
animationSpec = SwipeableV2Defaults.AnimationSpec,
confirmValueChange = confirmValueChange,
)
internal val offset: Float? get() = swipeableState.offset
companion object {
/**
* The default [Saver] implementation for [SheetState].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean
) = Saver<SheetState, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
}
)
}
}
/**
* Possible values of [SheetState].
*/
@ExperimentalMaterial3Api
enum class SheetValue {
/**
* The sheet is not visible.
*/
Hidden,
/**
* The sheet is visible at full height.
*/
Expanded,
/**
* The sheet is partially visible.
*/
PartiallyExpanded,
}
/**
* Contains the default values used by [ModalBottomSheet] and [BottomSheetScaffold].
*/
@Stable
@ExperimentalMaterial3Api
object BottomSheetDefaults {
/** The default shape for a bottom sheets in [PartiallyExpanded] and [Expanded] states. */
val ExpandedShape: Shape
@Composable get() = ShapeDefaults.ExtraLarge.top()
/** The default container color for a bottom sheet. */
val ContainerColor: Color
@Composable get() = MaterialTheme.colorScheme.surface
/** The default elevation for a bottom sheet. */
val Elevation = 1.dp
/** The default color of the scrim overlay for background content. */
val ScrimColor: Color
@Composable get() = MaterialTheme.colorScheme.scrim.copy(.32f)
/**
* The default peek height used by [BottomSheetScaffold].
*/
val SheetPeekHeight = 56.dp
/**
* Default insets to be used and consumed by the [ModalBottomSheet] window.
*/
val windowInsets: WindowInsets
@Composable
get() = WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
/**
* The optional visual marker placed on top of a bottom sheet to indicate it may be dragged.
*/
@Composable
fun DragHandle(
modifier: Modifier = Modifier,
width: Dp = 32.dp,
height: Dp = 4.dp,
shape: Shape = MaterialTheme.shapes.extraLarge,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(.4f),
) {
Surface(
modifier = modifier
.padding(vertical = DragHandleVerticalPadding),
color = color,
shape = shape
) {
Box(
Modifier
.size(
width = width,
height = height
)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState,
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.Drag) {
sheetState.swipeableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.swipeableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
return if (toFling < 0 && currentOffset > sheetState.swipeableState.minOffset) {
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
}
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = Hidden,
skipHiddenState: Boolean = false,
): SheetState {
return rememberSaveable(
skipPartiallyExpanded, confirmValueChange,
saver = SheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange
)
) {
SheetState(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState)
}
}
private val DragHandleVerticalPadding = 22.dp
internal val BottomSheetMaxWidth = 640.dp
internal fun CornerBasedShape.top(): CornerBasedShape {
return copy(bottomStart = CornerSize(0.0.dp), bottomEnd = CornerSize(0.0.dp))
}

@ -0,0 +1,692 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This is a mirror of androidx.compose.material.SwipeableV2.kt from M2.
// DO NOT MODIFY DIRECTLY, make changes upstream and mirror them.
package org.tasks.compose.drawer
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.DragScope
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.OnRemeasuredModifier
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.InspectorValueInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* Enable swipe gestures between a set of predefined values.
*
* When a swipe is detected, the offset of the [SwipeableV2State] will be updated with the swipe
* delta. You should use this offset to move your content accordingly (see [Modifier.offset]).
* When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
* reached, the value of the [SwipeableV2State] will also be updated to the value corresponding to
* the new anchor.
*
* Swiping is constrained between the minimum and maximum anchors.
*
* @param state The associated [SwipeableV2State].
* @param orientation The orientation in which the swipeable can be swiped.
* @param enabled Whether this [swipeableV2] is enabled and should react to the user's input.
* @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
* swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
* @param interactionSource Optional [MutableInteractionSource] that will passed on to
* the internal [Modifier.draggable].
*/
@ExperimentalMaterial3Api
internal fun <T> Modifier.swipeableV2(
state: SwipeableV2State<T>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null
) = draggable(
state = state.swipeDraggableState,
orientation = orientation,
enabled = enabled,
interactionSource = interactionSource,
reverseDirection = reverseDirection,
startDragImmediately = state.isAnimationRunning,
onDragStopped = { velocity -> launch { state.settle(velocity) } }
)
/**
* Define anchor points for a given [SwipeableV2State] based on this node's layout size and update
* the state with them.
*
* @param state The associated [SwipeableV2State]
* @param possibleValues All possible values the [SwipeableV2State] could be in.
* @param anchorChangeHandler A callback to be invoked when the anchors have changed,
* `null` by default. Components with custom reconciliation logic should implement this callback,
* i.e. to re-target an in-progress animation.
* @param calculateAnchor This method will be invoked to calculate the position of all
* [possibleValues], given this node's layout size. Return the anchor's offset from the initial
* anchor, or `null` to indicate that a value does not have an anchor.
*/
@ExperimentalMaterial3Api
internal fun <T> Modifier.swipeAnchors(
state: SwipeableV2State<T>,
possibleValues: Set<T>,
anchorChangeHandler: AnchorChangeHandler<T>? = null,
calculateAnchor: (value: T, layoutSize: IntSize) -> Float?,
) = this.then(SwipeAnchorsModifier(
onDensityChanged = { state.density = it },
onSizeChanged = { layoutSize ->
val previousAnchors = state.anchors
val newAnchors = mutableMapOf<T, Float>()
possibleValues.forEach {
val anchorValue = calculateAnchor(it, layoutSize)
if (anchorValue != null) {
newAnchors[it] = anchorValue
}
}
if (previousAnchors != newAnchors) {
val previousTarget = state.targetValue
val stateRequiresCleanup = state.updateAnchors(newAnchors)
if (stateRequiresCleanup) {
anchorChangeHandler?.onAnchorsChanged(
previousTarget,
previousAnchors,
newAnchors
)
}
}
},
inspectorInfo = debugInspectorInfo {
name = "swipeAnchors"
properties["state"] = state
properties["possibleValues"] = possibleValues
properties["anchorChangeHandler"] = anchorChangeHandler
properties["calculateAnchor"] = calculateAnchor
}
))
/**
* State of the [swipeableV2] modifier.
*
* This contains necessary information about any ongoing swipe or animation and provides methods
* to change the state either immediately or by starting an animation. To create and remember a
* [SwipeableV2State] use [rememberSwipeableV2State].
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value. See the
* [fractionalPositionalThreshold] and [fixedPositionalThreshold] methods.
* @param velocityThreshold The velocity threshold (in dp per second) that the end velocity has to
* exceed in order to animate to the next state, even if the [positionalThreshold] has not been
* reached.
*/
@Stable
@ExperimentalMaterial3Api
internal class SwipeableV2State<T>(
initialValue: T,
internal val animationSpec: AnimationSpec<Float> = SwipeableV2Defaults.AnimationSpec,
internal val confirmValueChange: (newValue: T) -> Boolean = { true },
internal val positionalThreshold: Density.(totalDistance: Float) -> Float =
SwipeableV2Defaults.PositionalThreshold,
internal val velocityThreshold: Dp = SwipeableV2Defaults.VelocityThreshold,
) {
private val swipeMutex = InternalMutatorMutex()
internal val swipeDraggableState = object : DraggableState {
private val dragScope = object : DragScope {
override fun dragBy(pixels: Float) {
this@SwipeableV2State.dispatchRawDelta(pixels)
}
}
override suspend fun drag(
dragPriority: MutatePriority,
block: suspend DragScope.() -> Unit
) {
swipe(dragPriority) { dragScope.block() }
}
override fun dispatchRawDelta(delta: Float) {
this@SwipeableV2State.dispatchRawDelta(delta)
}
}
/**
* The current value of the [SwipeableV2State].
*/
var currentValue: T by mutableStateOf(initialValue)
private set
/**
* The target value. This is the closest value to the current offset (taking into account
* positional thresholds). If no interactions like animations or drags are in progress, this
* will be the current value.
*/
val targetValue: T by derivedStateOf {
animationTarget ?: run {
val currentOffset = offset
if (currentOffset != null) {
computeTarget(currentOffset, currentValue, velocity = 0f)
} else currentValue
}
}
/**
* The current offset, or null if it has not been initialized yet.
*
* The offset will be initialized during the first measurement phase of the node that the
* [swipeableV2] modifier is attached to. These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
* During the first composition, the offset will be null. 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.
*
* To guarantee stricter semantics, consider using [requireOffset].
*/
@get:Suppress("AutoBoxing")
var offset: Float? by mutableStateOf(null)
private set
/**
* Require the current offset.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = checkNotNull(offset) {
"The offset was read before being initialized. Did you access the offset in a phase " +
"before layout, like effects or composition?"
}
/**
* Whether an animation is currently in progress.
*/
val isAnimationRunning: Boolean get() = animationTarget != null
/**
* The fraction of the progress going from [currentValue] to [targetValue], within [0f..1f]
* bounds.
*/
/*@FloatRange(from = 0f, to = 1f)*/
val progress: Float by derivedStateOf {
val a = anchors[currentValue] ?: 0f
val b = anchors[targetValue] ?: 0f
val distance = abs(b - a)
if (distance > 1e-6f) {
val progress = (this.requireOffset() - a) / (b - a)
// If we are very close to 0f or 1f, we round to the closest
if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress
} else 1f
}
/**
* The velocity of the last known animation. Gets reset to 0f when an animation completes
* successfully, but does not get reset when an animation gets interrupted.
* You can use this value to provide smooth reconciliation behavior when re-targeting an
* animation.
*/
var lastVelocity: Float by mutableStateOf(0f)
private set
/**
* The minimum offset this state can reach. This will be the smallest anchor, or
* [Float.NEGATIVE_INFINITY] if the anchors are not initialized yet.
*/
val minOffset by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY }
/**
* The maximum offset this state can reach. This will be the biggest anchor, or
* [Float.POSITIVE_INFINITY] if the anchors are not initialized yet.
*/
val maxOffset by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY }
private var animationTarget: T? by mutableStateOf(null)
internal var anchors by mutableStateOf(emptyMap<T, Float>())
internal var density: Density? = null
/**
* Update the anchors.
* If the previous set of anchors was empty, attempt to update the offset to match the initial
* value's anchor.
*
* @return true if the state needs to be adjusted after updating the anchors, e.g. if the
* initial value is not found in the initial set of anchors. false if no further updates are
* needed.
*/
internal fun updateAnchors(newAnchors: Map<T, Float>): Boolean {
val previousAnchorsEmpty = anchors.isEmpty()
anchors = newAnchors
val initialValueHasAnchor = if (previousAnchorsEmpty) {
val initialValue = currentValue
val initialValueAnchor = anchors[initialValue]
val initialValueHasAnchor = initialValueAnchor != null
if (initialValueHasAnchor) trySnapTo(initialValue)
initialValueHasAnchor
} else true
return !initialValueHasAnchor || !previousAnchorsEmpty
}
/**
* Whether the [value] has an anchor associated with it.
*/
fun hasAnchorForValue(value: T): Boolean = anchors.containsKey(value)
/**
* Snap to a [targetValue] without any animation.
* 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
*/
suspend fun snapTo(targetValue: T) {
swipe { snap(targetValue) }
}
/**
* 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
* @param velocity The velocity the animation should start with, [lastVelocity] by default
*/
suspend fun animateTo(
targetValue: T,
velocity: Float = lastVelocity,
) {
val targetOffset = anchors[targetValue]
if (targetOffset != null) {
try {
swipe {
animationTarget = targetValue
var prev = offset ?: 0f
animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
// Our onDrag coerces the value within the bounds, but an animation may
// overshoot, for example a spring animation or an overshooting interpolator
// We respect the user's intention and allow the overshoot, but still use
// DraggableState's drag for its mutex.
offset = value
prev = value
lastVelocity = velocity
}
lastVelocity = 0f
}
} finally {
animationTarget = null
val endOffset = requireOffset()
val endState = anchors
.entries
.firstOrNull { (_, anchorOffset) -> abs(anchorOffset - endOffset) < 0.5f }
?.key
this.currentValue = endState ?: currentValue
}
} else {
currentValue = targetValue
}
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
suspend fun settle(velocity: Float) {
val previousValue = this.currentValue
val targetValue = computeTarget(
offset = requireOffset(),
currentValue = previousValue,
velocity = velocity
)
if (confirmValueChange(targetValue)) {
animateTo(targetValue, velocity)
} else {
// If the user vetoed the state change, rollback to the previous state.
animateTo(previousValue, velocity)
}
}
/**
* Swipe by the [delta], coerce it in the bounds and dispatch it to the [SwipeableV2State].
*
* @return The delta the consumed by the [SwipeableV2State]
*/
fun dispatchRawDelta(delta: Float): Float {
val currentDragPosition = offset ?: 0f
val potentiallyConsumed = currentDragPosition + delta
val clamped = potentiallyConsumed.coerceIn(minOffset, maxOffset)
val deltaToConsume = clamped - currentDragPosition
if (abs(deltaToConsume) >= 0) {
offset = ((offset ?: 0f) + deltaToConsume).coerceIn(minOffset, maxOffset)
}
return deltaToConsume
}
private fun computeTarget(
offset: Float,
currentValue: T,
velocity: Float
): T {
val currentAnchors = anchors
val currentAnchor = currentAnchors[currentValue]
val currentDensity = requireDensity()
val velocityThresholdPx = with(currentDensity) { velocityThreshold.toPx() }
return if (currentAnchor == offset || currentAnchor == null) {
currentValue
} else if (currentAnchor < offset) {
// Swiping from lower to upper (positive).
if (velocity >= velocityThresholdPx) {
currentAnchors.closestAnchor(offset, true)
} else {
val upper = currentAnchors.closestAnchor(offset, true)
val distance = abs(currentAnchors.getValue(upper) - currentAnchor)
val relativeThreshold = abs(positionalThreshold(currentDensity, distance))
val absoluteThreshold = abs(currentAnchor + relativeThreshold)
if (offset < absoluteThreshold) currentValue else upper
}
} else {
// Swiping from upper to lower (negative).
if (velocity <= -velocityThresholdPx) {
currentAnchors.closestAnchor(offset, false)
} else {
val lower = currentAnchors.closestAnchor(offset, false)
val distance = abs(currentAnchor - currentAnchors.getValue(lower))
val relativeThreshold = abs(positionalThreshold(currentDensity, distance))
val absoluteThreshold = abs(currentAnchor - relativeThreshold)
if (offset < 0) {
// For negative offsets, larger absolute thresholds are closer to lower anchors
// than smaller ones.
if (abs(offset) < absoluteThreshold) currentValue else lower
} else {
if (offset > absoluteThreshold) currentValue else lower
}
}
}
}
private fun requireDensity() = requireNotNull(density) {
"SwipeableState did not have a density attached. Are you using Modifier.swipeable with " +
"this=$this SwipeableState?"
}
private suspend fun swipe(
swipePriority: MutatePriority = MutatePriority.Default,
action: suspend () -> Unit
): Unit = coroutineScope { swipeMutex.mutate(swipePriority, action) }
/**
* Attempt to snap synchronously. Snapping can happen synchronously when there is no other swipe
* transaction like a drag or an animation is progress. If there is another interaction in
* progress, the suspending [snapTo] overload needs to be used.
*
* @return true if the synchronous snap was successful, or false if we couldn't snap synchronous
*/
internal fun trySnapTo(targetValue: T): Boolean = swipeMutex.tryMutate { snap(targetValue) }
private fun snap(targetValue: T) {
val targetOffset = anchors[targetValue]
if (targetOffset != null) {
dispatchRawDelta(targetOffset - (offset ?: 0f))
currentValue = targetValue
animationTarget = null
} else {
currentValue = targetValue
}
}
companion object {
/**
* The default [Saver] implementation for [SwipeableV2State].
*/
@ExperimentalMaterial3Api
fun <T : Any> Saver(
animationSpec: AnimationSpec<Float>,
confirmValueChange: (T) -> Boolean,
positionalThreshold: Density.(distance: Float) -> Float,
velocityThreshold: Dp
) = Saver<SwipeableV2State<T>, T>(
save = { it.currentValue },
restore = {
SwipeableV2State(
initialValue = it,
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold
)
}
)
}
}
/**
* Create and remember a [SwipeableV2State].
*
* @param initialValue The initial value.
* @param animationSpec The default animation that will be used to animate to a new value.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending value change.
*/
@Composable
@ExperimentalMaterial3Api
internal fun <T : Any> rememberSwipeableV2State(
initialValue: T,
animationSpec: AnimationSpec<Float> = SwipeableV2Defaults.AnimationSpec,
confirmValueChange: (newValue: T) -> Boolean = { true }
): SwipeableV2State<T> {
return rememberSaveable(
initialValue, animationSpec, confirmValueChange,
saver = SwipeableV2State.Saver(
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = SwipeableV2Defaults.PositionalThreshold,
velocityThreshold = SwipeableV2Defaults.VelocityThreshold
),
) {
SwipeableV2State(
initialValue = initialValue,
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = SwipeableV2Defaults.PositionalThreshold,
velocityThreshold = SwipeableV2Defaults.VelocityThreshold
)
}
}
/**
* Expresses a fixed positional threshold of [threshold] dp. This will be the distance from an
* anchor that needs to be reached for [SwipeableV2State] to settle to the next closest anchor.
*
* @see [fractionalPositionalThreshold] for a fractional positional threshold
*/
@ExperimentalMaterial3Api
internal fun fixedPositionalThreshold(threshold: Dp): Density.(distance: Float) -> Float = {
threshold.toPx()
}
/**
* Expresses a relative positional threshold of the [fraction] of the distance to the closest anchor
* in the current direction. This will be the distance from an anchor that needs to be reached for
* [SwipeableV2State] to settle to the next closest anchor.
*
* @see [fixedPositionalThreshold] for a fixed positional threshold
*/
@ExperimentalMaterial3Api
internal fun fractionalPositionalThreshold(
fraction: Float
): Density.(distance: Float) -> Float = { distance -> distance * fraction }
/**
* Contains useful defaults for [swipeableV2] and [SwipeableV2State].
*/
@Stable
@ExperimentalMaterial3Api
internal object SwipeableV2Defaults {
/**
* The default animation used by [SwipeableV2State].
*/
@ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>()
/**
* The default velocity threshold (1.8 dp per millisecond) used by [rememberSwipeableV2State].
*/
@ExperimentalMaterial3Api
val VelocityThreshold: Dp = 125.dp
/**
* The default positional threshold (56 dp) used by [rememberSwipeableV2State]
*/
@ExperimentalMaterial3Api
val PositionalThreshold: Density.(totalDistance: Float) -> Float =
fixedPositionalThreshold(56.dp)
/**
* A [AnchorChangeHandler] implementation that attempts to reconcile an in-progress animation
* by re-targeting it if necessary or finding the closest new anchor.
* If the previous anchor is not in the new set of anchors, this implementation will snap to the
* closest anchor.
*
* Consider implementing a custom handler for more complex components like sheets.
* The [animate] and [snap] lambdas hoist the animation and snap logic. Usually these will just
* delegate to [SwipeableV2State].
*
* @param state The [SwipeableV2State] the change handler will read from
* @param animate A lambda that gets invoked to start an animation to a new target
* @param snap A lambda that gets invoked to snap to a new target
*/
@ExperimentalMaterial3Api
internal fun <T> ReconcileAnimationOnAnchorChangeHandler(
state: SwipeableV2State<T>,
animate: (target: T, velocity: Float) -> Unit,
snap: (target: T) -> Unit
) = AnchorChangeHandler { previousTarget, previousAnchors, newAnchors ->
val previousTargetOffset = previousAnchors[previousTarget]
val newTargetOffset = newAnchors[previousTarget]
if (previousTargetOffset != newTargetOffset) {
if (newTargetOffset != null) {
animate(previousTarget, state.lastVelocity)
} else {
snap(newAnchors.closestAnchor(offset = state.requireOffset()))
}
}
}
}
/**
* Defines a callback that is invoked when the anchors have changed.
*
* Components with custom reconciliation logic should implement this callback, for example to
* re-target an in-progress animation when the anchors change.
*
* @see SwipeableV2Defaults.ReconcileAnimationOnAnchorChangeHandler for a default implementation
*/
@ExperimentalMaterial3Api
internal fun interface AnchorChangeHandler<T> {
/**
* Callback that is invoked when the anchors have changed, after the [SwipeableV2State] has been
* updated with them. Use this hook to re-launch animations or interrupt them if needed.
*
* @param previousTargetValue The target value before the anchors were updated
* @param previousAnchors The previously set anchors
* @param newAnchors The newly set anchors
*/
fun onAnchorsChanged(
previousTargetValue: T,
previousAnchors: Map<T, Float>,
newAnchors: Map<T, Float>
)
}
@Stable
private class SwipeAnchorsModifier(
private val onDensityChanged: (density: Density) -> Unit,
private val onSizeChanged: (layoutSize: IntSize) -> Unit,
inspectorInfo: InspectorInfo.() -> Unit,
) : LayoutModifier, OnRemeasuredModifier, InspectorValueInfo(inspectorInfo) {
private var lastDensity: Float = -1f
private var lastFontScale: Float = -1f
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
if (density != lastDensity || fontScale != lastFontScale) {
onDensityChanged(Density(density, fontScale))
lastDensity = density
lastFontScale = fontScale
}
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
override fun onRemeasured(size: IntSize) {
onSizeChanged(size)
}
override fun toString() = "SwipeAnchorsModifierImpl(updateDensity=$onDensityChanged, " +
"onSizeChanged=$onSizeChanged)"
}
private fun <T> Map<T, Float>.closestAnchor(
offset: Float = 0f,
searchUpwards: Boolean = false
): T {
require(isNotEmpty()) { "The anchors were empty when trying to find the closest anchor" }
return minBy { (_, anchor) ->
val delta = if (searchUpwards) anchor - offset else offset - anchor
if (delta < 0) Float.POSITIVE_INFINITY else delta
}.key
}
private fun <T> Map<T, Float>.minOrNull() = minOfOrNull { (_, offset) -> offset }
private fun <T> Map<T, Float>.maxOrNull() = maxOfOrNull { (_, offset) -> offset }

@ -1302,6 +1302,15 @@ stable class Header {
stable val type: Function0<NavigationDrawerSubheader>
<runtime stability> = Stable
}
stable class SheetState {
stable val skipPartiallyExpanded: Boolean
stable val skipHiddenState: Boolean
stable var swipeableState: SwipeableV2State<SheetValue>
}
stable class BottomSheetDefaults {
stable val Elevation: Dp
stable val SheetPeekHeight: Dp
}
unstable class DashClockExtension {
unstable val job: CompletableJob
unstable val scope: CoroutineScope

@ -397,6 +397,90 @@ restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compo
stable content: Function2<Composer, Int, Unit>
stable onClick: Function0<Unit>? = @static null
)
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ModalBottomSheet(
stable onDismissRequest: Function0<Unit>
stable modifier: Modifier? = @static Companion
stable sheetState: SheetState? = @dynamic rememberModalBottomSheetState(false, null, $composer, 0, 0b0011)
stable shape: Shape? = @dynamic BottomSheetDefaults.<get-ExpandedShape>($composer, 0b0110)
stable containerColor: Color = @dynamic BottomSheetDefaults.<get-ContainerColor>($composer, 0b0110)
stable contentColor: Color = @dynamic contentColorFor(containerColor, $composer, 0b1110 and $dirty shr 0b1100)
stable tonalElevation: Dp = @static BottomSheetDefaults.Elevation
stable scrimColor: Color = @dynamic BottomSheetDefaults.<get-ScrimColor>($composer, 0b0110)
stable dragHandle: Function2<Composer, Int, Unit>? = @static ComposableSingletons$ModalBottomSheetKt.lambda-1
stable windowInsets: WindowInsets? = @dynamic BottomSheetDefaults.<get-windowInsets>($composer, 0b0110)
stable content: @[ExtensionFunctionType] Function3<ColumnScope, Composer, Int, Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ModalBottomSheet(
stable onDismissRequest: Function0<Unit>
stable modifier: Modifier? = @static Companion
stable sheetState: SheetState? = @dynamic rememberModalBottomSheetState(false, null, $composer, 0, 0b0011)
stable shape: Shape? = @dynamic BottomSheetDefaults.<get-ExpandedShape>($composer, 0b0110)
stable containerColor: Color = @dynamic BottomSheetDefaults.<get-ContainerColor>($composer, 0b0110)
stable contentColor: Color = @dynamic contentColorFor(containerColor, $composer, 0b1110 and $dirty shr 0b1100)
stable tonalElevation: Dp = @static BottomSheetDefaults.Elevation
stable scrimColor: Color = @dynamic BottomSheetDefaults.<get-ScrimColor>($composer, 0b0110)
stable dragHandle: Function2<Composer, Int, Unit>? = @static ComposableSingletons$ModalBottomSheetKt.lambda-2
stable content: @[ExtensionFunctionType] Function3<ColumnScope, Composer, Int, Unit>
)
fun rememberModalBottomSheetState(
stable skipPartiallyExpanded: Boolean = @static false
stable confirmValueChange: Function1<SheetValue, Boolean>? = @static { it: SheetValue ->
true
}
): SheetState
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Scrim(
stable color: Color
stable onDismissRequest: Function0<Unit>
stable visible: Boolean
)
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable]]") fun ModalBottomSheetPopup(
stable onDismissRequest: Function0<Unit>
stable windowInsets: WindowInsets
stable content: Function2<Composer, Int, Unit>
)
restartable fun Content(
unstable <this>: ModalBottomSheetWindow
)
fun <get-ExpandedShape>(
unused stable <this>: BottomSheetDefaults
): Shape
fun <get-ContainerColor>(
unused stable <this>: BottomSheetDefaults
): Color
fun <get-ScrimColor>(
unused stable <this>: BottomSheetDefaults
): Color
fun <get-windowInsets>(
unused stable <this>: BottomSheetDefaults
): WindowInsets
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun DragHandle(
stable modifier: Modifier? = @static Companion
stable width: Dp = @static 32.dp
stable height: Dp = @static 4.dp
stable shape: Shape? = @dynamic MaterialTheme.<get-shapes>($composer, MaterialTheme.$stable).extraLarge
stable color: Color = @dynamic MaterialTheme.<get-colorScheme>($composer, MaterialTheme.$stable).onSurfaceVariant.copy(
alpha = 0.4f
)
unused stable <this>: BottomSheetDefaults
)
fun rememberSheetState(
stable skipPartiallyExpanded: Boolean = @static false
stable confirmValueChange: Function1<SheetValue, Boolean>? = @static { it: SheetValue ->
true
}
stable initialValue: SheetValue? = @static SheetValue.Hidden
stable skipHiddenState: Boolean = @static false
): SheetState
fun rememberSwipeableV2State(
initialValue: T
unstable animationSpec: AnimationSpec<Float>? = @static SwipeableV2Defaults.AnimationSpec
stable confirmValueChange: Function1<@[ParameterName(name = 'newValue')] T, Boolean>? = @static { it: T ->
true
}
): SwipeableV2State<T>
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun TaskListDrawer(
stable begForMoney: Boolean
filters: ImmutableList<DrawerItem>

@ -1,25 +1,25 @@
{
"skippableComposables": 387,
"restartableComposables": 514,
"skippableComposables": 400,
"restartableComposables": 531,
"readonlyComposables": 0,
"totalComposables": 520,
"restartGroups": 514,
"totalGroups": 630,
"staticArguments": 792,
"certainArguments": 329,
"knownStableArguments": 5083,
"knownUnstableArguments": 138,
"totalComposables": 544,
"restartGroups": 531,
"totalGroups": 659,
"staticArguments": 802,
"certainArguments": 360,
"knownStableArguments": 5198,
"knownUnstableArguments": 150,
"unknownStableArguments": 11,
"totalArguments": 5232,
"markedStableClasses": 0,
"totalArguments": 5359,
"markedStableClasses": 2,
"inferredStableClasses": 108,
"inferredUnstableClasses": 334,
"inferredUncertainClasses": 1,
"effectivelyStableClasses": 108,
"totalClasses": 443,
"memoizedLambdas": 549,
"singletonLambdas": 188,
"singletonComposableLambdas": 94,
"composableLambdas": 238,
"totalLambdas": 667
"effectivelyStableClasses": 110,
"totalClasses": 445,
"memoizedLambdas": 572,
"singletonLambdas": 195,
"singletonComposableLambdas": 97,
"composableLambdas": 246,
"totalLambdas": 695
}
Loading…
Cancel
Save