From 94a719cb663a97e2ee3416012823a6e229e91c8f Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sat, 28 Oct 2023 03:21:24 -0500 Subject: [PATCH] Improve menu dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy M3 ModalBottomSheet to add 'skipPartiallyCollapsed' support 😕 --- .../todoroo/astrid/activity/MainActivity.kt | 37 +- .../compose/drawer/InternalAtomicReference.kt | 25 + .../compose/drawer/InternalMutatorMutex.kt | 160 ++++ .../tasks/compose/drawer/ModalBottomSheet.kt | 534 ++++++++++++++ .../org/tasks/compose/drawer/SheetState.kt | 427 +++++++++++ .../org/tasks/compose/drawer/SwipeableV2.kt | 692 ++++++++++++++++++ .../app_googleplayDebug-classes.txt | 9 + .../app_googleplayDebug-composables.txt | 84 +++ .../app_googleplayDebug-module.json | 36 +- 9 files changed, 1981 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt create mode 100644 app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt create mode 100644 app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt create mode 100644 app/src/main/java/org/tasks/compose/drawer/SheetState.kt create mode 100644 app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt diff --git a/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt b/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt index d71544d19..864e66870 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt @@ -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, diff --git a/app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt b/app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt new file mode 100644 index 000000000..2e4417739 --- /dev/null +++ b/app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt @@ -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 = + java.util.concurrent.atomic.AtomicReference \ No newline at end of file diff --git a/app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt b/app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt new file mode 100644 index 000000000..2008da078 --- /dev/null +++ b/app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt @@ -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(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 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 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 + } +} diff --git a/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt b/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt new file mode 100644 index 000000000..64822747c --- /dev/null +++ b/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt @@ -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 + +/** + * Material Design modal bottom sheet. + * + * 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, + 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 { 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 + } +} diff --git a/app/src/main/java/org/tasks/compose/drawer/SheetState.kt b/app/src/main/java/org/tasks/compose/drawer/SheetState.kt new file mode 100644 index 000000000..80c16d81a --- /dev/null +++ b/app/src/main/java/org/tasks/compose/drawer/SheetState.kt @@ -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( + 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)) +} diff --git a/app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt b/app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt new file mode 100644 index 000000000..76b78a5d8 --- /dev/null +++ b/app/src/main/java/org/tasks/compose/drawer/SwipeableV2.kt @@ -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 Modifier.swipeableV2( + state: SwipeableV2State, + 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 Modifier.swipeAnchors( + state: SwipeableV2State, + possibleValues: Set, + anchorChangeHandler: AnchorChangeHandler? = null, + calculateAnchor: (value: T, layoutSize: IntSize) -> Float?, +) = this.then(SwipeAnchorsModifier( + onDensityChanged = { state.density = it }, + onSizeChanged = { layoutSize -> + val previousAnchors = state.anchors + val newAnchors = mutableMapOf() + 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( + initialValue: T, + internal val animationSpec: AnimationSpec = 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()) + + 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): 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 Saver( + animationSpec: AnimationSpec, + confirmValueChange: (T) -> Boolean, + positionalThreshold: Density.(distance: Float) -> Float, + velocityThreshold: Dp + ) = Saver, 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 rememberSwipeableV2State( + initialValue: T, + animationSpec: AnimationSpec = SwipeableV2Defaults.AnimationSpec, + confirmValueChange: (newValue: T) -> Boolean = { true } +): SwipeableV2State { + 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() + + /** + * 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 ReconcileAnimationOnAnchorChangeHandler( + state: SwipeableV2State, + 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 { + + /** + * 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, + newAnchors: Map + ) +} + +@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 Map.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 Map.minOrNull() = minOfOrNull { (_, offset) -> offset } +private fun Map.maxOrNull() = maxOfOrNull { (_, offset) -> offset } diff --git a/compose-metrics/app_googleplayDebug-classes.txt b/compose-metrics/app_googleplayDebug-classes.txt index 3e11def1e..444720203 100644 --- a/compose-metrics/app_googleplayDebug-classes.txt +++ b/compose-metrics/app_googleplayDebug-classes.txt @@ -1302,6 +1302,15 @@ stable class Header { stable val type: Function0 = Stable } +stable class SheetState { + stable val skipPartiallyExpanded: Boolean + stable val skipHiddenState: Boolean + stable var swipeableState: SwipeableV2State +} +stable class BottomSheetDefaults { + stable val Elevation: Dp + stable val SheetPeekHeight: Dp +} unstable class DashClockExtension { unstable val job: CompletableJob unstable val scope: CoroutineScope diff --git a/compose-metrics/app_googleplayDebug-composables.txt b/compose-metrics/app_googleplayDebug-composables.txt index 74e1baf96..631c80666 100644 --- a/compose-metrics/app_googleplayDebug-composables.txt +++ b/compose-metrics/app_googleplayDebug-composables.txt @@ -397,6 +397,90 @@ restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compo stable content: Function2 stable onClick: Function0? = @static null ) +restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ModalBottomSheet( + stable onDismissRequest: Function0 + stable modifier: Modifier? = @static Companion + stable sheetState: SheetState? = @dynamic rememberModalBottomSheetState(false, null, $composer, 0, 0b0011) + stable shape: Shape? = @dynamic BottomSheetDefaults.($composer, 0b0110) + stable containerColor: Color = @dynamic BottomSheetDefaults.($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.($composer, 0b0110) + stable dragHandle: Function2? = @static ComposableSingletons$ModalBottomSheetKt.lambda-1 + stable windowInsets: WindowInsets? = @dynamic BottomSheetDefaults.($composer, 0b0110) + stable content: @[ExtensionFunctionType] Function3 +) +restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ModalBottomSheet( + stable onDismissRequest: Function0 + stable modifier: Modifier? = @static Companion + stable sheetState: SheetState? = @dynamic rememberModalBottomSheetState(false, null, $composer, 0, 0b0011) + stable shape: Shape? = @dynamic BottomSheetDefaults.($composer, 0b0110) + stable containerColor: Color = @dynamic BottomSheetDefaults.($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.($composer, 0b0110) + stable dragHandle: Function2? = @static ComposableSingletons$ModalBottomSheetKt.lambda-2 + stable content: @[ExtensionFunctionType] Function3 +) +fun rememberModalBottomSheetState( + stable skipPartiallyExpanded: Boolean = @static false + stable confirmValueChange: Function1? = @static { it: SheetValue -> + true +} + +): SheetState +restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Scrim( + stable color: Color + stable onDismissRequest: Function0 + stable visible: Boolean +) +restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable]]") fun ModalBottomSheetPopup( + stable onDismissRequest: Function0 + stable windowInsets: WindowInsets + stable content: Function2 +) +restartable fun Content( + unstable : ModalBottomSheetWindow +) +fun ( + unused stable : BottomSheetDefaults +): Shape +fun ( + unused stable : BottomSheetDefaults +): Color +fun ( + unused stable : BottomSheetDefaults +): Color +fun ( + unused stable : 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.($composer, MaterialTheme.$stable).extraLarge + stable color: Color = @dynamic MaterialTheme.($composer, MaterialTheme.$stable).onSurfaceVariant.copy( + alpha = 0.4f +) + unused stable : BottomSheetDefaults +) +fun rememberSheetState( + stable skipPartiallyExpanded: Boolean = @static false + stable confirmValueChange: Function1? = @static { it: SheetValue -> + true +} + + stable initialValue: SheetValue? = @static SheetValue.Hidden + stable skipHiddenState: Boolean = @static false +): SheetState +fun rememberSwipeableV2State( + initialValue: T + unstable animationSpec: AnimationSpec? = @static SwipeableV2Defaults.AnimationSpec + stable confirmValueChange: Function1<@[ParameterName(name = 'newValue')] T, Boolean>? = @static { it: T -> + true +} + +): SwipeableV2State restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun TaskListDrawer( stable begForMoney: Boolean filters: ImmutableList diff --git a/compose-metrics/app_googleplayDebug-module.json b/compose-metrics/app_googleplayDebug-module.json index 8561715fb..31b4eabcd 100644 --- a/compose-metrics/app_googleplayDebug-module.json +++ b/compose-metrics/app_googleplayDebug-module.json @@ -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 } \ No newline at end of file