chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:31 +08:00
commit f06de36198
4585 changed files with 588418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+23
View File
@@ -0,0 +1,23 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* 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.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
plugins {
alias(libs.plugins.image.toolbox.library)
alias(libs.plugins.image.toolbox.compose)
}
android.namespace = "com.t8rin.modalsheet"
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ ImageToolbox is an image editor for android
~ Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~
~ 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.
~
~ You should have received a copy of the Apache License
~ along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
-->
<manifest package="com.t8rin.modalsheet" />
@@ -0,0 +1,223 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* 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.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.modalsheet
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.R
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.core.view.children
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
/**
* Opens a popup with the given content.
* The popup is visible as long as it is part of the composition hierarchy.
*
* Note: This is highly reduced version of the official Popup composable with some changes:
* * Fixes an issue with action mode (copy-paste) menu, see https://issuetracker.google.com/issues/216662636
* * Adds the view to the decor view of the window, instead of the window itself.
* * Do not have properties, as Popup is laid out as fullscreen.
*
* @param onDismiss Executes when the user clicks outside of the popup.
* @param content The content to be displayed inside the popup.
*/
@Composable
fun FullscreenPopup(
onDismiss: (() -> Unit)? = null,
placeAboveAll: Boolean = false,
content: @Composable () -> Unit
) {
val view = LocalView.current
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val popupId = rememberSaveable { UUID.randomUUID() }
val popupLayout = remember {
PopupLayout(
onDismiss = onDismiss,
composeView = view,
popupId = popupId,
placeAboveAll = placeAboveAll
).apply {
setContent(parentComposition) {
Box(Modifier.semantics { this.popup() }) {
currentContent()
}
}
}
}
DisposableEffect(popupLayout) {
popupLayout.show()
popupLayout.updateParameters(
onDismiss = onDismiss,
placeAboveAll = placeAboveAll
)
onDispose {
popupLayout.disposeComposition()
// Remove the window
popupLayout.dismiss()
}
}
SideEffect {
popupLayout.updateParameters(
onDismiss = onDismiss,
placeAboveAll = placeAboveAll
)
}
}
/**
* The layout the popup uses to display its content.
*/
@SuppressLint("ViewConstructor")
private class PopupLayout(
private var onDismiss: (() -> Unit)?,
composeView: View,
popupId: UUID,
private var placeAboveAll: Boolean
) : AbstractComposeView(composeView.context), ViewRootForInspector {
private val decorView = findOwner<Activity>(composeView.context)?.window?.decorView as ViewGroup
override val subCompositionView: AbstractComposeView get() = this
init {
id = android.R.id.content
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
// Set unique id for AbstractComposeView. This allows state restoration for the state
// defined inside the Popup via rememberSaveable()
setTag(R.id.compose_view_saveable_id_tag, "Popup:$popupId")
setTag(R.id.consume_window_insets_tag, false)
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
fun show() {
// Place popup above all current views
var placeAboveAllView: View? = null
val topView = decorView.children.maxBy {
if (it.z == ABOVE_ALL_Z) {
placeAboveAllView = it
-ABOVE_ALL_Z
} else it.z
}
z = if (placeAboveAll) ABOVE_ALL_Z
else topView.z + 1
decorView.addView(
this,
0,
MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
)
placeAboveAllView?.bringToFront()
(decorView as View).invalidate()
requestFocus()
}
fun setContent(parent: CompositionContext, content: @Composable () -> Unit) {
setParentCompositionContext(parent)
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
@Composable
override fun Content() {
content()
}
@Suppress("ReturnCount")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK && onDismiss != null) {
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) {
onDismiss?.invoke()
return true
}
}
}
return super.dispatchKeyEvent(event)
}
fun updateParameters(
onDismiss: (() -> Unit)?,
placeAboveAll: Boolean
) {
this.onDismiss = onDismiss
this.placeAboveAll = placeAboveAll
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
decorView.removeView(this)
}
}
private inline fun <reified T> findOwner(context: Context): T? {
var innerContext = context
while (innerContext is ContextWrapper) {
if (innerContext is T) {
return innerContext
}
innerContext = innerContext.baseContext
}
return null
}
private const val ABOVE_ALL_Z = Float.MAX_VALUE
@@ -0,0 +1,224 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* 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.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
package com.t8rin.modalsheet
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.Dp
import androidx.compose.ui.zIndex
/**
* Modal sheet that behaves like bottom sheet and draws over system UI.
* Should be used on with the content which is not dependent on the outer data. For dynamic content use [ModalSheet]
* overload with a 'data' parameter.
*
* @param visible True if modal should be visible.
* @param onVisibleChange Called when visibility changes.
* @param cancelable When true, this modal sheet can be closed with swipe gesture, tap on scrim or tap on hardware back
* button. Note: passing 'false' does not disable the interaction with the sheet. Only the resulting state after the
* sheet settles.
* @param shape The shape of the bottom sheet.
* @param elevation The elevation of the bottom sheet.
* @param containerColor The background color of the bottom sheet.
* @param contentColor The preferred content color provided by the bottom sheet to its
* children. Defaults to the matching content color for [containerColor], or if that is not
* a color from the theme, this will keep the same content color set above the bottom sheet.
* @param scrimColor The color of the scrim that is applied to the rest of the screen when the
* bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no
* longer be applied and the bottom sheet will not block interaction with the rest of the screen
* when visible.
* @param content The content of the bottom sheet.
*/
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ModalSheet(
visible: Boolean,
onVisibleChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
dragHandle: @Composable ColumnScope.() -> Unit = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
BottomSheetDefaults.DragHandle()
}
},
nestedScrollEnabled: Boolean = false,
animationSpec: AnimationSpec<Float> = SwipeableV2Defaults.AnimationSpec,
sheetModifier: Modifier = Modifier,
cancelable: Boolean = true,
skipHalfExpanded: Boolean = true,
shape: Shape = BottomSheetDefaults.ExpandedShape,
elevation: Dp = BottomSheetDefaults.Elevation,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
scrimColor: Color = BottomSheetDefaults.ScrimColor,
content: @Composable ColumnScope.() -> Unit,
) {
// Hold cancelable flag internally and set to true when modal sheet is dismissed via "visible" property in
// non-cancellable modal sheet. This ensures that "confirmValueChange" will return true when sheet is set to hidden
// state.
val internalCancelable = remember { mutableStateOf(cancelable) }
val sheetState = rememberModalBottomSheetState(
skipHalfExpanded = skipHalfExpanded,
initialValue = ModalBottomSheetValue.Hidden,
animationSpec = animationSpec,
confirmValueChange = {
// Intercept and disallow hide gesture / action
if (it == ModalBottomSheetValue.Hidden && !internalCancelable.value) {
return@rememberModalBottomSheetState false
}
true
},
)
LaunchedEffect(visible, cancelable) {
if (visible) {
internalCancelable.value = cancelable
sheetState.show()
} else {
internalCancelable.value = true
sheetState.hide()
}
}
LaunchedEffect(sheetState.currentValue, sheetState.targetValue, sheetState.progress) {
if (sheetState.progress == 1f && sheetState.currentValue == sheetState.targetValue) {
val newVisible = sheetState.isVisible
if (newVisible != visible) {
onVisibleChange(newVisible)
}
}
}
if (!visible && sheetState.currentValue == sheetState.targetValue && !sheetState.isVisible) {
return
}
ModalSheet(
sheetState = sheetState,
onDismiss = {
if (cancelable) {
onVisibleChange(false)
}
},
dragHandle = dragHandle,
nestedScrollEnabled = nestedScrollEnabled,
sheetModifier = sheetModifier,
modifier = modifier,
shape = shape,
elevation = elevation,
containerColor = containerColor,
contentColor = contentColor,
scrimColor = scrimColor,
content = content,
)
}
/**
* Modal sheet that behaves like bottom sheet and draws over system UI.
* Takes [ModalSheetState] as parameter to fine-tune sheet behavior.
*
* Note: In this case [ModalSheet] is always added to the composition. See [ModalSheet] overload with visible parameter,
* or data object to conditionally add / remove modal sheet to / from the composition.
*
* @param sheetState The state of the underlying Material bottom sheet.
* @param onDismiss Called when user taps on the hardware back button.
* @param shape The shape of the bottom sheet.
* @param elevation The elevation of the bottom sheet.
* @param containerColor The background color of the bottom sheet.
* @param contentColor The preferred content color provided by the bottom sheet to its
* children. Defaults to the matching content color for [containerColor], or if that is not
* a color from the theme, this will keep the same content color set above the bottom sheet.
* @param scrimColor The color of the scrim that is applied to the rest of the screen when the
* bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no
* longer be applied and the bottom sheet will not block interaction with the rest of the screen
* when visible.
* @param content The content of the bottom sheet.
*/
@ExperimentalMaterial3Api
@Composable
fun ModalSheet(
modifier: Modifier = Modifier,
sheetModifier: Modifier = Modifier,
dragHandle: @Composable ColumnScope.() -> Unit = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
BottomSheetDefaults.DragHandle()
}
},
sheetState: ModalSheetState,
onDismiss: (() -> Unit)?,
nestedScrollEnabled: Boolean = false,
shape: Shape = BottomSheetDefaults.ExpandedShape,
elevation: Dp = BottomSheetDefaults.Elevation,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
scrimColor: Color = BottomSheetDefaults.ScrimColor,
content: @Composable ColumnScope.() -> Unit,
) {
FullscreenPopup(
onDismiss = onDismiss,
) {
Box(Modifier.fillMaxSize()) {
ModalSheetLayout(
nestedScrollEnabled = nestedScrollEnabled,
sheetModifier = sheetModifier,
dragHandle = {
Column(Modifier.zIndex(100f)) {
dragHandle()
}
},
modifier = modifier.align(Alignment.BottomCenter),
sheetState = sheetState,
sheetShape = shape,
sheetElevation = elevation,
sheetContainerColor = containerColor,
sheetContentColor = contentColor,
scrimColor = scrimColor,
sheetContent = {
Column(Modifier.zIndex(-100f)) {
content()
}
},
content = {}
)
}
}
}
@@ -0,0 +1,569 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* 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.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
@file:Suppress("FunctionName")
package com.t8rin.modalsheet
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.SpringSpec
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.layout.Arrangement
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Surface
import androidx.compose.material.contentColorFor
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.t8rin.modalsheet.ModalBottomSheetValue.Expanded
import com.t8rin.modalsheet.ModalBottomSheetValue.HalfExpanded
import com.t8rin.modalsheet.ModalBottomSheetValue.Hidden
import com.t8rin.modalsheet.ModalSheetState.Companion.Saver
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.roundToInt
enum class ModalBottomSheetValue {
/**
* The bottom sheet is not visible.
*/
Hidden,
/**
* The bottom sheet is visible at full height.
*/
Expanded,
/**
* The bottom sheet is partially visible at 50% of the screen height. This state is only
* enabled if the height of the bottom sheet is more than 50% of the screen height.
*/
HalfExpanded
}
/**
* State of the [ModalSheetLayout] composable.
*
* @param initialValue The initial value of the state. <b>Must not be set to
* [ModalBottomSheetValue.HalfExpanded] if [isSkipHalfExpanded] is set to true.</b>
* @param animationSpec The default animation that will be used to animate to a new state.
* @param isSkipHalfExpanded Whether the half 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.
* <b>Must not be set to true if the initialValue is [ModalBottomSheetValue.HalfExpanded].</b>
* If supplied with [ModalBottomSheetValue.HalfExpanded] for the initialValue, an
* [IllegalArgumentException] will be thrown.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@ExperimentalMaterial3Api
@OptIn(ExperimentalMaterialApi::class)
class ModalSheetState(
initialValue: ModalBottomSheetValue,
animationSpec: AnimationSpec<Float> = SwipeableV2Defaults.AnimationSpec,
confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true },
val isSkipHalfExpanded: Boolean = false
) {
val swipeableState = SwipeableV2State(
initialValue = initialValue,
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = PositionalThreshold,
velocityThreshold = VelocityThreshold
)
val currentValue: ModalBottomSheetValue
get() = swipeableState.currentValue
val targetValue: ModalBottomSheetValue
get() = swipeableState.targetValue
val progress: Float
get() = swipeableState.progress
/**
* Whether the bottom sheet is visible.
*/
val isVisible: Boolean
get() = swipeableState.currentValue != Hidden
val hasHalfExpandedState: Boolean
get() = swipeableState.hasAnchorForValue(HalfExpanded)
init {
if (isSkipHalfExpanded) {
require(initialValue != HalfExpanded) {
"The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" +
" true."
}
}
}
/**
* Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller
* than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be
* fully expanded.
*
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasHalfExpandedState -> HalfExpanded
else -> Expanded
}
animateTo(targetValue)
}
/**
* Half expand the bottom sheet if half expand is enabled with animation and suspend until it
* animation is complete or cancelled
*
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun halfExpand() {
if (!hasHalfExpandedState) {
return
}
animateTo(HalfExpanded)
}
/**
* Fully expand the bottom sheet with animation and suspend until it if fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
if (!swipeableState.hasAnchorForValue(Expanded)) {
return
}
animateTo(Expanded)
}
/**
* Hide the bottom sheet with animation and suspend until it if fully hidden or animation has
* been canceled.
*
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() = animateTo(Hidden)
suspend fun animateTo(
target: ModalBottomSheetValue,
velocity: Float = swipeableState.lastVelocity
) = swipeableState.animateTo(target, velocity)
suspend fun snapTo(target: ModalBottomSheetValue) = swipeableState.snapTo(target)
fun requireOffset() = swipeableState.requireOffset()
val lastVelocity: Float get() = swipeableState.lastVelocity
val isAnimationRunning: Boolean get() = swipeableState.isAnimationRunning
companion object {
/**
* The default [Saver] implementation for [ModalSheetState].
* Saves the [currentValue] and recreates a [ModalSheetState] with the saved value as
* initial value.
*/
fun Saver(
animationSpec: AnimationSpec<Float>,
confirmValueChange: (ModalBottomSheetValue) -> Boolean,
skipHalfExpanded: Boolean,
): Saver<ModalSheetState, *> = Saver(
save = { it.currentValue },
restore = {
ModalSheetState(
initialValue = it,
animationSpec = animationSpec,
isSkipHalfExpanded = skipHalfExpanded,
confirmValueChange = confirmValueChange
)
}
)
}
}
/**
* Create a [ModalSheetState] and [remember] it.
*
* @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 skipHalfExpanded Whether the half 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.
* <b>Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded].</b>
* If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an
* [IllegalArgumentException] will be thrown.
*/
@OptIn(ExperimentalMaterialApi::class)
@ExperimentalMaterial3Api
@Composable
fun rememberModalBottomSheetState(
initialValue: ModalBottomSheetValue,
animationSpec: AnimationSpec<Float> = SpringSpec(),
confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true },
skipHalfExpanded: Boolean = false,
): ModalSheetState {
// Key the rememberSaveable against the initial value. If it changed we don't want to attempt
// to restore as the restored value could have been saved with a now invalid set of anchors.
// b/152014032
return key(initialValue) {
rememberSaveable(
initialValue, animationSpec, skipHalfExpanded, confirmValueChange,
saver = Saver(
animationSpec = animationSpec,
skipHalfExpanded = skipHalfExpanded,
confirmValueChange = confirmValueChange
)
) {
ModalSheetState(
initialValue = initialValue,
animationSpec = animationSpec,
isSkipHalfExpanded = skipHalfExpanded,
confirmValueChange = confirmValueChange
)
}
}
}
/**
* <a href="https://material.io/components/sheets-bottom#modal-bottom-sheet" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets present a set of choices while blocking interaction with the rest of the
* screen. They are an alternative to inline menus and simple dialogs, providing
* additional room for content, iconography, and actions.
*
* ![Modal bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/modal-bottom-sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
*
* @param sheetContent The content of the bottom sheet.
* @param modifier Optional [Modifier] for the entire component.
* @param sheetState The state of the bottom sheet.
* @param sheetShape The shape of the bottom sheet.
* @param sheetElevation The elevation of the bottom sheet.
* @param sheetContainerColor The background color of the bottom sheet.
* @param sheetContentColor The preferred content color provided by the bottom sheet to its
* children. Defaults to the matching content color for [sheetContainerColor], or if that is not
* a color from the theme, this will keep the same content color set above the bottom sheet.
* @param scrimColor The color of the scrim that is applied to the rest of the screen when the
* bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no
* longer be applied and the bottom sheet will not block interaction with the rest of the screen
* when visible.
* @param content The content of rest of the screen.
*/
@OptIn(ExperimentalMaterialApi::class)
@ExperimentalMaterial3Api
@Composable
fun ModalSheetLayout(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
dragHandle: @Composable ColumnScope.() -> Unit = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
BottomSheetDefaults.DragHandle()
}
},
nestedScrollEnabled: Boolean = true,
sheetModifier: Modifier = Modifier,
sheetState: ModalSheetState = rememberModalBottomSheetState(Hidden),
sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
sheetElevation: Dp = BottomSheetDefaults.Elevation,
sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
sheetContentColor: Color = contentColorFor(sheetContainerColor),
scrimColor: Color = BottomSheetDefaults.ScrimColor,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
val orientation = Orientation.Vertical
val anchorChangeHandler = remember(sheetState, scope) {
ModalBottomSheetAnchorChangeHandler(
state = sheetState,
animateTo = { target, velocity ->
scope.launch { sheetState.animateTo(target, velocity = velocity) }
},
snapTo = { target -> scope.launch { sheetState.snapTo(target) } }
)
}
BoxWithConstraints(modifier) {
val fullHeight = constraints.maxHeight.toFloat()
Box(Modifier.fillMaxSize()) {
content()
Scrim(
color = scrimColor,
onDismiss = {
if (sheetState.swipeableState.confirmValueChange(Hidden)) {
scope.launch { sheetState.hide() }
}
},
visible = sheetState.swipeableState.targetValue != Hidden
)
}
Surface(
Modifier
.align(Alignment.TopCenter) // We offset from the top so we'll center from there
.widthIn(max = MaxModalBottomSheetWidth)
.fillMaxWidth()
.then(
if (nestedScrollEnabled) {
Modifier.nestedScroll(
remember(sheetState.swipeableState, orientation) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
state = sheetState.swipeableState,
orientation = orientation
)
}
)
} else Modifier)
.offset {
IntOffset(
0,
sheetState.swipeableState
.requireOffset()
.roundToInt()
)
}
.swipeableV2(
state = sheetState.swipeableState,
orientation = orientation,
enabled = sheetState.swipeableState.currentValue != Hidden,
)
.swipeAnchors(
state = sheetState.swipeableState,
possibleValues = setOf(Hidden, HalfExpanded, Expanded),
anchorChangeHandler = anchorChangeHandler
) { state, sheetSize ->
when (state) {
Hidden -> fullHeight
HalfExpanded -> when {
sheetSize.height < fullHeight / 2f -> null
sheetState.isSkipHalfExpanded -> null
else -> fullHeight / 2f
}
Expanded -> if (sheetSize.height != 0) {
max(0f, fullHeight - sheetSize.height)
} else null
}
}
.semantics {
if (sheetState.isVisible) {
dismiss {
if (sheetState.swipeableState.confirmValueChange(Hidden)) {
scope.launch { sheetState.hide() }
}
true
}
if (sheetState.swipeableState.currentValue == HalfExpanded) {
expand {
if (sheetState.swipeableState.confirmValueChange(Expanded)) {
scope.launch { sheetState.expand() }
}
true
}
} else if (sheetState.hasHalfExpandedState) {
collapse {
if (sheetState.swipeableState.confirmValueChange(HalfExpanded)) {
scope.launch { sheetState.halfExpand() }
}
true
}
}
}
}
.then(sheetModifier),
shape = sheetShape,
elevation = sheetElevation,
color = sheetContainerColor,
contentColor = sheetContentColor
) {
Column(
content = {
dragHandle()
sheetContent()
}
)
}
}
}
@Composable
private fun Scrim(
color: Color,
onDismiss: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val dismissModifier = if (visible) {
Modifier
.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
.semantics(mergeDescendants = true) {
onClick { onDismiss(); true }
}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissModifier)
) {
drawRect(color = color, alpha = alpha)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
state: SwipeableV2State<*>,
orientation: Orientation
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.UserInput) {
state.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.UserInput) {
state.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = state.requireOffset()
return if (toFling < 0 && currentOffset > state.minOffset) {
state.settle(velocity = 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 {
state.settle(velocity = 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
}
@ExperimentalMaterial3Api
@OptIn(ExperimentalMaterialApi::class)
private fun ModalBottomSheetAnchorChangeHandler(
state: ModalSheetState,
animateTo: (target: ModalBottomSheetValue, velocity: Float) -> Unit,
snapTo: (target: ModalBottomSheetValue) -> Unit,
) = AnchorChangeHandler<ModalBottomSheetValue> { previousTarget, previousAnchors, newAnchors ->
val previousTargetOffset = previousAnchors[previousTarget]
val newTarget = when (previousTarget) {
Hidden -> Hidden
HalfExpanded, Expanded -> {
val hasHalfExpandedState = newAnchors.containsKey(HalfExpanded)
val newTarget = if (hasHalfExpandedState) HalfExpanded
else if (newAnchors.containsKey(Expanded)) Expanded else Hidden
newTarget
}
}
val newTargetOffset = newAnchors.getValue(newTarget)
if (newTargetOffset != previousTargetOffset) {
if (state.isAnimationRunning) {
// Re-target the animation to the new offset if it changed
animateTo(newTarget, state.lastVelocity)
} else {
// Snap to the new offset value of the target if no animation was running
snapTo(newTarget)
}
}
}
private val PositionalThreshold: Density.(Float) -> Float = { 56.dp.toPx() }
private val VelocityThreshold = 125.dp
private val MaxModalBottomSheetWidth = 640.dp
@@ -0,0 +1,680 @@
/*
* ImageToolbox is an image editor for android
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* 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.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*/
@file:Suppress("FunctionName")
package com.t8rin.modalsheet
/*
* 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.
*/
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animate
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.material.ExperimentalMaterialApi
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 com.t8rin.modalsheet.SwipeableV2State.Companion.Saver
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.abs
/**
* 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].
*/
@ExperimentalMaterialApi
internal fun <T> Modifier.swipeableV2(
state: SwipeableV2State<T>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null
) = draggable(
state = state.draggableState,
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.
*/
@ExperimentalMaterialApi
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
@ExperimentalMaterialApi
class SwipeableV2State<T>(
initialValue: T,
val animationSpec: AnimationSpec<Float> = SwipeableV2Defaults.AnimationSpec,
val confirmValueChange: (newValue: T) -> Boolean = { true },
val positionalThreshold: Density.(totalDistance: Float) -> Float =
SwipeableV2Defaults.PositionalThreshold,
val velocityThreshold: Dp = SwipeableV2Defaults.VelocityThreshold,
) {
/**
* 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)
val draggableState = DraggableState {
offset = ((offset ?: 0f) + it).coerceIn(minOffset, maxOffset)
}
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 initialValueAnchor = anchors[currentValue]
val initialValueHasAnchor = initialValueAnchor != null
if (initialValueHasAnchor) offset = initialValueAnchor
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) {
val targetOffset = anchors[targetValue]
if (targetOffset != null) {
try {
draggableState.drag {
animationTarget = targetValue
dragBy(targetOffset - requireOffset())
}
this.currentValue = targetValue
} finally {
animationTarget = null
}
} else {
currentValue = 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 {
draggableState.drag {
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 [draggableState].
*
* @return The delta the [draggableState] will consume
*/
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) {
draggableState.dispatchRawDelta(deltaToConsume)
}
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?"
}
companion object {
/**
* The default [Saver] implementation for [SwipeableV2State].
*/
@ExperimentalMaterialApi
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
@ExperimentalMaterialApi
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 = 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
*/
@ExperimentalMaterialApi
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
*/
@ExperimentalMaterialApi
internal fun fractionalPositionalThreshold(
fraction: Float
): Density.(distance: Float) -> Float = { distance -> distance * fraction }
/**
* Contains useful defaults for [swipeableV2] and [SwipeableV2State].
*/
@Stable
@ExperimentalMaterialApi
internal object SwipeableV2Defaults {
/**
* The default animation used by [SwipeableV2State].
*/
@ExperimentalMaterialApi
val AnimationSpec = SpringSpec<Float>()
/**
* The default velocity threshold (1.8 dp per millisecond) used by [rememberSwipeableV2State].
*/
@ExperimentalMaterialApi
val VelocityThreshold: Dp = 125.dp
/**
* The default positional threshold (56 dp) used by [rememberSwipeableV2State]
*/
@ExperimentalMaterialApi
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
*/
@ExperimentalMaterialApi
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
*/
@ExperimentalMaterialApi
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 }
private fun <T> Map<T, Float>.requireAnchor(value: T) = requireNotNull(this[value]) {
"Required anchor $value was not found in anchors. Current anchors: ${this.toMap()}"
}