package me.eternal.purrfect.core.whatsapp import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas as AndroidCanvas import android.graphics.drawable.Drawable import android.os.Build import android.view.MotionEvent import android.view.View import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.foundation.Image import androidx.compose.foundation.MutatorMutex import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape 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.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastRoundToInt import androidx.compose.ui.util.lerp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberCombinedBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.Shadow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max import kotlin.math.sign private val WhatsAppGreen = Color(0xFF25D366) private val WhatsAppGreenDark = Color(0xFF00A884) internal data class WhatsAppLiquidGlassTab( val title: String, val icon: Drawable? ) internal class WhatsAppLiquidGlassComposeOverlay(context: Context) : AbstractComposeView(context) { private var tabs by mutableStateOf(emptyList()) private var selectedIndex by mutableIntStateOf(0) private var backdropBitmap by mutableStateOf(null) private var onTabSelected: (Int) -> Unit = {} private val owner = LiquidGlassViewTreeOwner() init { setViewTreeLifecycleOwner(owner) setViewTreeSavedStateRegistryOwner(owner) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) setWillNotDraw(false) isClickable = false isFocusable = false } fun installViewTreeOwners(root: View) { var current: View? = root while (current != null) { current.setViewTreeLifecycleOwner(owner) current.setViewTreeSavedStateRegistryOwner(owner) current = current.parent as? View } setViewTreeLifecycleOwner(owner) setViewTreeSavedStateRegistryOwner(owner) } fun update( tabs: List, selectedIndex: Int, backdropBitmap: Bitmap? = null, onTabSelected: ((Int) -> Unit)? = null ) { if (tabs.isNotEmpty()) this.tabs = tabs this.selectedIndex = selectedIndex.coerceIn(0, max(0, this.tabs.size - 1)) if (backdropBitmap != null) this.backdropBitmap = backdropBitmap if (onTabSelected != null) this.onTabSelected = onTabSelected } override fun dispatchTouchEvent(event: MotionEvent): Boolean { val hitTop = height - resources.displayMetrics.density * 104f return if (event.y >= hitTop) super.dispatchTouchEvent(event) else false } override fun onDetachedFromWindow() { super.onDetachedFromWindow() owner.destroy() } @Composable override fun Content() { val localTabs = tabs if (localTabs.isEmpty()) return WhatsAppLiquidGlassBottomOverlay( tabs = localTabs, selectedIndex = selectedIndex, backdropBitmap = backdropBitmap, onTabSelected = { index -> selectedIndex = index onTabSelected(index) } ) } } private class LiquidGlassViewTreeOwner : LifecycleOwner, SavedStateRegistryOwner { private val lifecycleRegistry = LifecycleRegistry(this) private val savedStateController = SavedStateRegistryController.create(this) override val lifecycle: Lifecycle = lifecycleRegistry override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry init { savedStateController.performAttach() savedStateController.performRestore(null) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } fun destroy() { if (lifecycleRegistry.currentState == Lifecycle.State.DESTROYED) return lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } } @Composable private fun WhatsAppLiquidGlassBottomOverlay( tabs: List, selectedIndex: Int, backdropBitmap: Bitmap?, onTabSelected: (Int) -> Unit ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { val backdrop = rememberLayerBackdrop() if (backdropBitmap != null) { Image( painter = BitmapPainter(backdropBitmap.asImageBitmap()), contentDescription = null, modifier = Modifier .alpha(0f) .layerBackdrop(backdrop) .fillMaxSize() ) } else { Box( Modifier .alpha(0f) .layerBackdrop(backdrop) .fillMaxSize() ) } var currentIndex by remember { mutableIntStateOf(selectedIndex) } LaunchedEffect(selectedIndex) { currentIndex = selectedIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0)) } LiquidBottomTabsExact( selectedTabIndex = { currentIndex }, onTabSelected = { index -> currentIndex = index onTabSelected(index) }, backdrop = backdrop, tabsCount = tabs.size, modifier = Modifier .padding(horizontal = 24f.dp) .navigationBarsPadding() .padding(bottom = 10f.dp) ) { tabs.forEachIndexed { index, tab -> LiquidBottomTabExact({ currentIndex = index; onTabSelected(index) }) { val selected = index == currentIndex DrawableTabIcon(tab.icon, selected) BasicText( tab.title, style = TextStyle( color = if (selected) WhatsAppGreen else Color(0xFFD0D7DB), fontSize = 12f.sp ), maxLines = 1 ) } } } } } @Composable private fun DrawableTabIcon(icon: Drawable?, selected: Boolean) { val tint = when { selected -> WhatsAppGreen isSystemInDarkTheme() -> Color.White else -> Color.Black } val bitmap = remember(icon) { icon?.toBitmap(28) } if (bitmap != null) { Image( painter = BitmapPainter(bitmap.asImageBitmap()), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = Modifier.size(28f.dp) ) } else { Box(Modifier.size(28f.dp)) } } private fun Drawable.toBitmap(sizeDp: Int): Bitmap? { return runCatching { val density = 3f val size = (sizeDp * density).toInt() Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also { bitmap -> val canvas = AndroidCanvas(bitmap) val drawable = constantState?.newDrawable()?.mutate() ?: mutate() drawable.setBounds(0, 0, size, size) drawable.draw(canvas) } }.getOrNull() } private val LocalLiquidBottomTabScale = staticCompositionLocalOf { { 1f } } @Composable private fun RowScope.LiquidBottomTabExact( onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit ) { val scale = LocalLiquidBottomTabScale.current Column( modifier .clip(RoundedCornerShape(percent = 50)) .clickable( interactionSource = null, indication = null, role = Role.Tab, onClick = onClick ) .fillMaxHeight() .weight(1f) .graphicsLayer { val scaleValue = scale() scaleX = scaleValue scaleY = scaleValue }, verticalArrangement = Arrangement.spacedBy(2f.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, content = content ) } @Composable private fun LiquidBottomTabsExact( selectedTabIndex: () -> Int, onTabSelected: (index: Int) -> Unit, backdrop: Backdrop, tabsCount: Int, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit ) { val isLightTheme = !isSystemInDarkTheme() val accentColor = if (isLightTheme) WhatsAppGreenDark else WhatsAppGreen val containerColor = if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) else Color(0xFF121212).copy(0.4f) val tabsBackdrop = rememberLayerBackdrop() BoxWithConstraints( modifier, contentAlignment = Alignment.CenterStart ) { val density = LocalDensity.current val tabWidth = with(density) { (constraints.maxWidth.toFloat() - 8f.dp.toPx()) / tabsCount } val offsetAnimation = remember { Animatable(0f) } val panelOffset by remember(density) { derivedStateOf { val fraction = (offsetAnimation.value / constraints.maxWidth).fastCoerceIn(-1f, 1f) with(density) { 4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction)) } } } val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val animationScope = rememberCoroutineScope() var currentIndex by remember(selectedTabIndex) { mutableIntStateOf(selectedTabIndex()) } val dampedDragAnimation = remember(animationScope) { DampedDragAnimation( animationScope = animationScope, initialValue = selectedTabIndex().toFloat(), valueRange = 0f..(tabsCount - 1).toFloat(), visibilityThreshold = 0.001f, initialScale = 1f, pressedScale = 78f / 56f, onDragStarted = {}, onDragStopped = { val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1) currentIndex = targetIndex animateToValue(targetIndex.toFloat()) animationScope.launch { offsetAnimation.animateTo( 0f, spring(1f, 300f, 0.5f) ) } }, onDrag = { _, dragAmount -> updateValue( (targetValue + dragAmount.x / tabWidth * if (isLtr) 1f else -1f) .fastCoerceIn(0f, (tabsCount - 1).toFloat()) ) animationScope.launch { offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x) } } ) } LaunchedEffect(selectedTabIndex) { snapshotFlow { selectedTabIndex() } .collectLatest { index -> currentIndex = index } } LaunchedEffect(dampedDragAnimation) { snapshotFlow { currentIndex } .drop(1) .collectLatest { index -> dampedDragAnimation.animateToValue(index.toFloat()) onTabSelected(index) } } val interactiveHighlight = remember(animationScope) { InteractiveHighlight( animationScope = animationScope, position = { size, _ -> Offset( if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset, size.height / 2f ) } ) } Row( Modifier .graphicsLayer { translationX = panelOffset } .drawBackdrop( backdrop = backdrop, shape = { RoundedCornerShape(percent = 50) }, effects = { vibrancy() blur(8f.dp.toPx()) lens(24f.dp.toPx(), 24f.dp.toPx()) }, layerBlock = { val progress = dampedDragAnimation.pressProgress val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) scaleX = scale scaleY = scale }, onDrawSurface = { drawRect(containerColor) } ) .then(interactiveHighlight.modifier) .height(64f.dp) .fillMaxWidth() .padding(4f.dp), verticalAlignment = Alignment.CenterVertically, content = content ) CompositionLocalProvider( LocalLiquidBottomTabScale provides { lerp(1f, 1.2f, dampedDragAnimation.pressProgress) } ) { Row( Modifier .clearAndSetSemantics {} .alpha(0f) .layerBackdrop(tabsBackdrop) .graphicsLayer { translationX = panelOffset } .drawBackdrop( backdrop = backdrop, shape = { RoundedCornerShape(percent = 50) }, effects = { val progress = dampedDragAnimation.pressProgress vibrancy() blur(8f.dp.toPx()) lens( 24f.dp.toPx() * progress, 24f.dp.toPx() * progress ) }, highlight = { val progress = dampedDragAnimation.pressProgress Highlight.Default.copy(alpha = progress) }, onDrawSurface = { drawRect(containerColor) } ) .then(interactiveHighlight.modifier) .height(56f.dp) .fillMaxWidth() .padding(horizontal = 4f.dp) .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), verticalAlignment = Alignment.CenterVertically, content = content ) } Box( Modifier .padding(horizontal = 4f.dp) .graphicsLayer { translationX = if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset } .then(interactiveHighlight.gestureModifier) .then(dampedDragAnimation.modifier) .drawBackdrop( backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop), shape = { RoundedCornerShape(percent = 50) }, effects = { val progress = dampedDragAnimation.pressProgress lens( 10f.dp.toPx() * progress, 14f.dp.toPx() * progress, chromaticAberration = true ) }, highlight = { val progress = dampedDragAnimation.pressProgress Highlight.Default.copy(alpha = progress) }, shadow = { val progress = dampedDragAnimation.pressProgress Shadow(alpha = progress) }, innerShadow = { val progress = dampedDragAnimation.pressProgress InnerShadow( radius = 8f.dp * progress, alpha = progress ) }, layerBlock = { scaleX = dampedDragAnimation.scaleX scaleY = dampedDragAnimation.scaleY val velocity = dampedDragAnimation.velocity / 10f scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) }, onDrawSurface = { val progress = dampedDragAnimation.pressProgress drawRect( if (isLightTheme) Color.Black.copy(0.1f) else Color.White.copy(0.1f), alpha = 1f - progress ) drawRect(Color.Black.copy(alpha = 0.03f * progress)) } ) .height(56f.dp) .fillMaxWidth(1f / tabsCount) ) } } private class DampedDragAnimation( private val animationScope: CoroutineScope, val initialValue: Float, val valueRange: ClosedRange, val visibilityThreshold: Float, val initialScale: Float, val pressedScale: Float, val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit, val onDragStopped: DampedDragAnimation.() -> Unit, val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit, ) { private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold) private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f) private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f) private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f) private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f) private val valueAnimation = Animatable(initialValue, visibilityThreshold) private val velocityAnimation = Animatable(0f, 5f) private val pressProgressAnimation = Animatable(0f, 0.001f) private val scaleXAnimation = Animatable(initialScale, 0.001f) private val scaleYAnimation = Animatable(initialScale, 0.001f) private val mutatorMutex = MutatorMutex() private val velocityTracker = VelocityTracker() val value: Float get() = valueAnimation.value val targetValue: Float get() = valueAnimation.targetValue val pressProgress: Float get() = pressProgressAnimation.value val scaleX: Float get() = scaleXAnimation.value val scaleY: Float get() = scaleYAnimation.value val velocity: Float get() = velocityAnimation.value val modifier: Modifier = Modifier.pointerInput(Unit) { inspectDragGestures( onDragStart = { down -> onDragStarted(down.position) press() }, onDragEnd = { onDragStopped() release() }, onDragCancel = { onDragStopped() release() } ) { _, dragAmount -> onDrag(size, dragAmount) } } fun press() { velocityTracker.resetTracking() animationScope.launch { launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) } launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) } } } fun release() { animationScope.launch { awaitFrame() if (value != targetValue) { val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f snapshotFlow { valueAnimation.value } .filter { abs(it - valueAnimation.targetValue) < threshold } .first() } launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) } launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) } } } fun updateValue(value: Float) { val targetValue = value.coerceIn(valueRange) animationScope.launch { launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) { updateVelocity() } } } } fun animateToValue(value: Float) { animationScope.launch { mutatorMutex.mutate { press() val targetValue = value.coerceIn(valueRange) launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) } if (velocity != 0f) { launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } } release() } } } private fun updateVelocity() { velocityTracker.addPosition( System.currentTimeMillis(), Offset(value, 0f) ) val targetVelocity = velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start) animationScope.launch { velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) } } } private class InteractiveHighlight( val animationScope: CoroutineScope, val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset } ) { private val pressProgressAnimationSpec = spring(0.5f, 300f, 0.001f) private val positionAnimationSpec = spring(0.5f, 300f, Offset.VisibilityThreshold) private val pressProgressAnimation = Animatable(0f, 0.001f) private val positionAnimation = Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold) private var startPosition = Offset.Zero private val shader = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { android.graphics.RuntimeShader( """ uniform float2 size; layout(color) uniform half4 color; uniform float radius; uniform float2 position; half4 main(float2 coord) { float dist = distance(coord, position); float intensity = smoothstep(radius, radius * 0.5, dist); return color * intensity; }""" ) } else { null } val modifier: Modifier = Modifier.drawWithContent { val progress = pressProgressAnimation.value if (progress > 0f) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) { drawRect(Color.White.copy(0.08f * progress), blendMode = BlendMode.Plus) shader.apply { val position = position(size, positionAnimation.value) setFloatUniform("size", size.width, size.height) setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) setFloatUniform("radius", size.minDimension * 1.5f) setFloatUniform( "position", position.x.fastCoerceIn(0f, size.width), position.y.fastCoerceIn(0f, size.height) ) } drawRect(ShaderBrush(shader), blendMode = BlendMode.Plus) } else { drawRect(Color.White.copy(0.25f * progress), blendMode = BlendMode.Plus) } } drawContent() } val gestureModifier: Modifier = Modifier.pointerInput(animationScope) { inspectDragGestures( onDragStart = { down -> startPosition = down.position animationScope.launch { launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } launch { positionAnimation.snapTo(startPosition) } } }, onDragEnd = { animationScope.launch { launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } } }, onDragCancel = { animationScope.launch { launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } } } ) { change, _ -> animationScope.launch { positionAnimation.snapTo(change.position) } } } } private suspend fun PointerInputScope.inspectDragGestures( onDragStart: (down: PointerInputChange) -> Unit = {}, onDragEnd: (change: PointerInputChange) -> Unit = {}, onDragCancel: () -> Unit = {}, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit ) { awaitEachGesture { val initialDown = awaitFirstDown(false, PointerEventPass.Initial) val down = awaitFirstDown(false) val drag = initialDown onDragStart(down) onDrag(drag, Offset.Zero) val upEvent = drag( pointerId = drag.id, onDrag = { onDrag(it, it.positionChange()) } ) if (upEvent == null) { onDragCancel() } else { onDragEnd(upEvent) } } } private suspend inline fun AwaitPointerEventScope.drag( pointerId: PointerId, onDrag: (PointerInputChange) -> Unit ): PointerInputChange? { val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true if (isPointerUp) return null var pointer = pointerId while (true) { val change = awaitDragOrUp(pointer) ?: return null if (change.isConsumed) return null if (change.changedToUpIgnoreConsumed()) return change onDrag(change) pointer = change.id } } private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( pointerId: PointerId ): PointerInputChange? { var pointer = pointerId while (true) { val event = awaitPointerEvent() val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null if (dragEvent.changedToUpIgnoreConsumed()) { val otherDown = event.changes.fastFirstOrNull { it.pressed } if (otherDown == null) return dragEvent else pointer = otherDown.id } else { val hasDragged = dragEvent.previousPosition != dragEvent.position if (hasDragged) return dragEvent } } }