chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* 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()}"
|
||||
}
|
||||
Reference in New Issue
Block a user