From 49ad6f5d6b47a165643347d2e2b475199b177f40 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Sat, 26 Oct 2024 17:21:49 +0700 Subject: [PATCH] Replace AnchorDrawable with Foundation's 1.7.0 Fixes https://github.com/composablehorizons/compose-unstyled/issues/35 --- core/src/androidMain/kotlin/Modal.android.kt | 15 +- .../compose/foundation/Expect.android.kt | 33 - .../compose/foundation/Expect.apple.kt | 23 - core/src/commonMain/kotlin/BottomSheet.kt | 170 +- .../src/commonMain/kotlin/ModalBottomSheet.kt | 2 + .../kotlin/androidx/annotation/FloatRange.kt | 49 + .../kotlin/androidx/annotation/IntRange.kt | 45 + .../kotlin/androidx/collection/FloatSet.kt} | 17 +- .../androidx/collection/ObjectFloatMap.kt | 1157 ++++++++++ .../kotlin/androidx/collection/ScatterMap.kt | 1979 +++++++++++++++++ .../kotlin/androidx/collection/ScatterSet.kt | 1102 +++++++++ .../collection/internal/ContainerHelpers.kt | 89 + .../collection/internal/RuntimeHelpers.kt | 50 + .../androidx/compose/foundation/Expect.kt | 29 - .../foundation/gestures/AnchoredDraggable.kt | 1218 ++++++++++ .../gestures/CoreAnchoredDraggable.kt | 749 ------- .../gestures/DragGestureDetector.kt | 957 ++++++++ .../compose/foundation/gestures/Draggable.kt | 648 ++++++ .../androidx/compose/foundation/Expect.js.kt | 23 - .../androidx/compose/foundation/Expect.jvm.kt | 33 - core/src/jvmTest/kotlin/BottomSheetTests.kt | 21 +- 21 files changed, 7429 insertions(+), 980 deletions(-) delete mode 100644 core/src/androidMain/kotlin/androidx/compose/foundation/Expect.android.kt delete mode 100644 core/src/appleMain/kotlin/androidx/compose/foundation/Expect.apple.kt create mode 100644 core/src/commonMain/kotlin/androidx/annotation/FloatRange.kt create mode 100644 core/src/commonMain/kotlin/androidx/annotation/IntRange.kt rename core/src/{wasmJsMain/kotlin/androidx/compose/foundation/Expect.wasmJs.kt => commonMain/kotlin/androidx/collection/FloatSet.kt} (65%) create mode 100644 core/src/commonMain/kotlin/androidx/collection/ObjectFloatMap.kt create mode 100644 core/src/commonMain/kotlin/androidx/collection/ScatterMap.kt create mode 100644 core/src/commonMain/kotlin/androidx/collection/ScatterSet.kt create mode 100644 core/src/commonMain/kotlin/androidx/collection/internal/ContainerHelpers.kt create mode 100644 core/src/commonMain/kotlin/androidx/collection/internal/RuntimeHelpers.kt delete mode 100644 core/src/commonMain/kotlin/androidx/compose/foundation/Expect.kt create mode 100644 core/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt delete mode 100644 core/src/commonMain/kotlin/androidx/compose/foundation/gestures/CoreAnchoredDraggable.kt create mode 100644 core/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt create mode 100644 core/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt delete mode 100644 core/src/jsMain/kotlin/androidx/compose/foundation/Expect.js.kt delete mode 100644 core/src/jvmMain/kotlin/androidx/compose/foundation/Expect.jvm.kt diff --git a/core/src/androidMain/kotlin/Modal.android.kt b/core/src/androidMain/kotlin/Modal.android.kt index 845c2d3..64031cf 100644 --- a/core/src/androidMain/kotlin/Modal.android.kt +++ b/core/src/androidMain/kotlin/Modal.android.kt @@ -50,11 +50,15 @@ internal actual fun Modal( ?: error("Attempted to get the dialog's window without content. This should never happen and it's a bug in the library. Kindly open an issue with the steps to reproduce so that we fix it ASAP: https://github.com/composablehorizons/compose-unstyled/issues/new") CompositionLocalProvider(LocalModalWindow provides localWindow) { Box(Modifier.onKeyEvent(onKeyEvent)) { - BackHandler { - val backKeyDown = NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_BACK) - val backPress = KeyEvent(backKeyDown) - onKeyEvent(backPress) - } + BackHandler( + onBack = { + val backKeyDown = NativeKeyEvent( + NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_BACK + ) + val backPress = KeyEvent(backKeyDown) + onKeyEvent(backPress) + } + ) content() } } @@ -78,6 +82,7 @@ internal actual fun Modal( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) } else { + @Suppress("DEPRECATION") window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } diff --git a/core/src/androidMain/kotlin/androidx/compose/foundation/Expect.android.kt b/core/src/androidMain/kotlin/androidx/compose/foundation/Expect.android.kt deleted file mode 100644 index f9da16b..0000000 --- a/core/src/androidMain/kotlin/androidx/compose/foundation/Expect.android.kt +++ /dev/null @@ -1,33 +0,0 @@ -// ktlint-disable filename - -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.foundation - -import kotlinx.coroutines.CancellationException - -internal actual abstract class CorePlatformOptimizedCancellationException actual constructor( - message: String? -) : CancellationException(message) { - - override fun fillInStackTrace(): Throwable { - // Avoid null.clone() on Android <= 6.0 when accessing stackTrace - stackTrace = emptyArray() - return this - } - -} diff --git a/core/src/appleMain/kotlin/androidx/compose/foundation/Expect.apple.kt b/core/src/appleMain/kotlin/androidx/compose/foundation/Expect.apple.kt deleted file mode 100644 index f4ec5f2..0000000 --- a/core/src/appleMain/kotlin/androidx/compose/foundation/Expect.apple.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.foundation - -import kotlin.coroutines.cancellation.CancellationException - -internal actual abstract class CorePlatformOptimizedCancellationException actual constructor( - message: String? -) : CancellationException(message) diff --git a/core/src/commonMain/kotlin/BottomSheet.kt b/core/src/commonMain/kotlin/BottomSheet.kt index 874a9b4..c971996 100644 --- a/core/src/commonMain/kotlin/BottomSheet.kt +++ b/core/src/commonMain/kotlin/BottomSheet.kt @@ -1,7 +1,12 @@ +@file:OptIn(ExperimentalFoundationApi::class) + package com.composables.core import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.tween +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Indication import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.* @@ -30,11 +35,11 @@ import kotlinx.coroutines.launch private fun Saver( animationSpec: AnimationSpec, - density: Density, coroutineScope: CoroutineScope, sheetDetents: List, velocityThreshold: () -> Float, positionalThreshold: (totalDistance: Float) -> Float, + decayAnimationSpec: DecayAnimationSpec, ): Saver = mapSaver(save = { mapOf("detent" to it.currentDetent.identifier) }, restore = { map -> val selectedDetentName = map["detent"] BottomSheetState( @@ -44,6 +49,7 @@ private fun Saver( animationSpec = animationSpec, velocityThreshold = velocityThreshold, positionalThreshold = positionalThreshold, + decayAnimationSpec = decayAnimationSpec, ) }) @@ -52,6 +58,7 @@ public fun rememberBottomSheetState( initialDetent: SheetDetent, detents: List = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), animationSpec: AnimationSpec = tween(), + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), velocityThreshold: () -> Dp = { 125.dp }, positionalThreshold: (totalDistance: Dp) -> Dp = { 56.dp } ): BottomSheetState { @@ -60,9 +67,8 @@ public fun rememberBottomSheetState( return rememberSaveable( saver = Saver( animationSpec = animationSpec, - density = density, - sheetDetents = detents, coroutineScope = scope, + sheetDetents = detents, velocityThreshold = { with(density) { velocityThreshold().toPx() @@ -72,7 +78,8 @@ public fun rememberBottomSheetState( with(density) { positionalThreshold(totalDistance.toDp()).toPx() } - } + }, + decayAnimationSpec = decayAnimationSpec ) ) { BottomSheetState( @@ -89,7 +96,8 @@ public fun rememberBottomSheetState( with(density) { positionalThreshold(totalDistance.toDp()).toPx() } - } + }, + decayAnimationSpec = decayAnimationSpec ) } } @@ -125,7 +133,8 @@ public class BottomSheetState internal constructor( private val coroutineScope: CoroutineScope, animationSpec: AnimationSpec, velocityThreshold: () -> Float, - positionalThreshold: (totalDistance: Float) -> Float + positionalThreshold: (totalDistance: Float) -> Float, + decayAnimationSpec: DecayAnimationSpec ) { init { check(detents.isNotEmpty()) { @@ -148,58 +157,61 @@ public class BottomSheetState internal constructor( internal var fullContentHeight = Float.NaN - internal val coreAnchoredDraggableState = CoreAnchoredDraggableState( + internal val anchoredDraggableState = AnchoredDraggableState( initialValue = initialDetent, positionalThreshold = positionalThreshold, velocityThreshold = velocityThreshold, - animationSpec = animationSpec + snapAnimationSpec = animationSpec, + decayAnimationSpec = decayAnimationSpec, ) public var currentDetent: SheetDetent - get() = coreAnchoredDraggableState.currentValue + get() = anchoredDraggableState.currentValue set(value) { check(detents.contains(value)) { "Tried to set currentDetent to an unknown detent with identifier ${value.identifier}. Make sure that the detent is passed to the list of detents when instantiating the sheet's state." } coroutineScope.launch { - coreAnchoredDraggableState.animateTo( - value, - coreAnchoredDraggableState.lastVelocity - ) + anchoredDraggableState.animateTo(value) } } public val targetDetent: SheetDetent - get() = coreAnchoredDraggableState.targetValue + get() = anchoredDraggableState.targetValue public val isIdle: Boolean by derivedStateOf { - progress == 1f && currentDetent == targetDetent && coreAnchoredDraggableState.isAnimationRunning.not() + progress == 1f && currentDetent == targetDetent && anchoredDraggableState.isAnimationRunning.not() } public val progress: Float - get() = coreAnchoredDraggableState.progress + get() = anchoredDraggableState.progress public val offset: Float by derivedStateOf { - if (coreAnchoredDraggableState.offset.isNaN() || closestDentToTop.isNaN()) { + if (anchoredDraggableState.offset.isNaN() || closestDentToTop.isNaN()) { 1f } else { - val offsetFromTop = coreAnchoredDraggableState.offset - closestDentToTop + val offsetFromTop = anchoredDraggableState.offset - closestDentToTop fullContentHeight - offsetFromTop } } - public suspend fun animateTo(value: SheetDetent, velocity: Float = coreAnchoredDraggableState.lastVelocity) { + @Deprecated("Velocity can no longer be set", ReplaceWith("animateTo(value)")) + public suspend fun animateTo(value: SheetDetent, velocity: Float = anchoredDraggableState.lastVelocity) { + animateTo(value) + } + + public suspend fun animateTo(value: SheetDetent) { check(detents.contains(value)) { "Tried to set currentDetent to an unknown detent with identifier ${value.identifier}. Make sure that the detent is passed to the list of detents when instantiating the sheet's state." } - coreAnchoredDraggableState.animateTo(value, velocity) + anchoredDraggableState.animateTo(value) } public fun jumpTo(value: SheetDetent) { check(detents.contains(value)) { "Tried to set currentDetent to an unknown detent with identifier ${value.identifier}. Make sure that the detent is passed to the list of detents when instantiating the sheet's state." } - coroutineScope.launch { coreAnchoredDraggableState.snapTo(value) } + coroutineScope.launch { anchoredDraggableState.snapTo(value) } } } @@ -220,6 +232,8 @@ public fun BottomSheet( val scope = remember { BottomSheetScope(state, enabled) } scope.enabled = enabled + val coroutineScope = rememberCoroutineScope() + BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { var containerHeight by remember { mutableStateOf(Dp.Unspecified) } state.fullContentHeight = Float.NaN @@ -238,7 +252,7 @@ public fun BottomSheet( it.onSizeChanged { sheetSize -> val sheetHeight = with(density) { sheetSize.height.toDp() } state.fullContentHeight = sheetSize.height.toFloat() - val anchors = CoreDraggableAnchors { + val anchors = DraggableAnchors { with(density) { state.closestDentToTop = Float.NaN @@ -257,12 +271,12 @@ public fun BottomSheet( } } val newTarget = if (state.isIdle) { - state.coreAnchoredDraggableState.currentValue + state.anchoredDraggableState.currentValue } else { - state.coreAnchoredDraggableState.targetValue + state.anchoredDraggableState.targetValue } - state.coreAnchoredDraggableState.updateAnchors(anchors, newTarget) + state.anchoredDraggableState.updateAnchors(anchors, newTarget) } } else it } @@ -284,8 +298,8 @@ public fun BottomSheet( } } .offset { - if (state.coreAnchoredDraggableState.offset.isNaN().not()) { - val requireOffset = state.coreAnchoredDraggableState.requireOffset() + if (state.anchoredDraggableState.offset.isNaN().not()) { + val requireOffset = state.anchoredDraggableState.requireOffset() val y = requireOffset.toInt() IntOffset(x = 0, y = y) } else { @@ -294,18 +308,20 @@ public fun BottomSheet( }.then( if (scope.enabled) { Modifier.nestedScroll( - remember(state.coreAnchoredDraggableState, Orientation.Vertical) { + remember(state.anchoredDraggableState, Orientation.Vertical) { ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( orientation = Orientation.Vertical, - sheetState = state, - draggableState = state.coreAnchoredDraggableState + sheetState = state.anchoredDraggableState, + onFling = { + coroutineScope.launch { state.anchoredDraggableState.settle(it) } + } ) }) } else Modifier ) - .coreAnchoredDraggable( - state.coreAnchoredDraggableState, - Orientation.Vertical, + .anchoredDraggable( + state = state.anchoredDraggableState, + orientation = Orientation.Vertical, enabled = scope.enabled ) .pointerInput(Unit) { detectTapGestures { } } @@ -318,61 +334,63 @@ public fun BottomSheet( } } -private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - draggableState: CoreAnchoredDraggableState<*>, +internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState: AnchoredDraggableState, orientation: Orientation, - sheetState: BottomSheetState -): NestedScrollConnection = object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (source == NestedScrollSource.Drag) { + onFling: (velocity: Float) -> Unit +): NestedScrollConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val delta = available.toFloat() - - val canDragSheetUp = delta < 0 && sheetState.offset > 0f - val canDragSheetDown = delta > 0 && sheetState.offset < 1f - - if (canDragSheetUp || canDragSheetDown) { - return draggableState.dispatchRawDelta(delta).toOffset() + return if (delta < 0 && source == NestedScrollSource.UserInput) { + sheetState.dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero } } - return Offset.Zero - } - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return if (source == NestedScrollSource.Drag) { - draggableState.dispatchRawDelta(available.toFloat()).toOffset() - } else { - Offset.Zero + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.UserInput) { + sheetState.dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } } - } - override suspend fun onPreFling(available: Velocity): Velocity { - val toFling = available.toFloat() - val currentOffset = draggableState.requireOffset() - return if (toFling < 0 && currentOffset > draggableState.anchors.minAnchor()) { - draggableState.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 onPreFling(available: Velocity): Velocity { + val toFling = available.toFloat() + val currentOffset = sheetState.requireOffset() + val minAnchor = sheetState.anchors.minAnchor() + return if (toFling < 0 && currentOffset > minAnchor) { + onFling(toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } } - } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - draggableState.settle(velocity = available.toFloat()) - return available - } + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + onFling(available.toFloat()) + return available + } - private fun Float.toOffset(): Offset = Offset( - x = if (orientation == Orientation.Horizontal) this else 0f, - y = if (orientation == Orientation.Vertical) this else 0f - ) + 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("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 -} + @JvmName("offsetToFloat") + private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y + } @Composable public fun BottomSheetScope.DragIndication( diff --git a/core/src/commonMain/kotlin/ModalBottomSheet.kt b/core/src/commonMain/kotlin/ModalBottomSheet.kt index 7b700de..55e1916 100644 --- a/core/src/commonMain/kotlin/ModalBottomSheet.kt +++ b/core/src/commonMain/kotlin/ModalBottomSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween +import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -42,6 +43,7 @@ public fun rememberModalBottomSheetState( animationSpec = animationSpec, velocityThreshold = velocityThreshold, positionalThreshold = positionalThreshold, + decayAnimationSpec = rememberSplineBasedDecay(), ) return rememberSaveable( saver = mapSaver( diff --git a/core/src/commonMain/kotlin/androidx/annotation/FloatRange.kt b/core/src/commonMain/kotlin/androidx/annotation/FloatRange.kt new file mode 100644 index 0000000..29b0be9 --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/annotation/FloatRange.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.annotation + +/** + * Denotes that the annotated element should be a float or double in the given range + * + * Example: + * ``` + * @FloatRange(from=0.0,to=1.0) + * public float getAlpha() { + * ... + * } + * ``` + */ +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.ANNOTATION_CLASS +) +public annotation class FloatRange( + /** Smallest value. Whether it is inclusive or not is determined by [.fromInclusive] */ + val from: Double = Double.NEGATIVE_INFINITY, + /** Largest value. Whether it is inclusive or not is determined by [.toInclusive] */ + val to: Double = Double.POSITIVE_INFINITY, + /** Whether the from value is included in the range */ + val fromInclusive: Boolean = true, + /** Whether the to value is included in the range */ + val toInclusive: Boolean = true +) diff --git a/core/src/commonMain/kotlin/androidx/annotation/IntRange.kt b/core/src/commonMain/kotlin/androidx/annotation/IntRange.kt new file mode 100644 index 0000000..106cfb9 --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/annotation/IntRange.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.annotation + +/** + * Denotes that the annotated element should be an int or long in the given range. + * + * Example: + * ``` + * @IntRange(from=0,to=255) + * public int getAlpha() { + * ... + * } + * ``` + */ +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.ANNOTATION_CLASS +) +public annotation class IntRange( + /** Smallest value, inclusive */ + val from: Long = Long.MIN_VALUE, + /** Largest value, inclusive */ + val to: Long = Long.MAX_VALUE +) diff --git a/core/src/wasmJsMain/kotlin/androidx/compose/foundation/Expect.wasmJs.kt b/core/src/commonMain/kotlin/androidx/collection/FloatSet.kt similarity index 65% rename from core/src/wasmJsMain/kotlin/androidx/compose/foundation/Expect.wasmJs.kt rename to core/src/commonMain/kotlin/androidx/collection/FloatSet.kt index f4ec5f2..28cfb12 100644 --- a/core/src/wasmJsMain/kotlin/androidx/compose/foundation/Expect.wasmJs.kt +++ b/core/src/commonMain/kotlin/androidx/collection/FloatSet.kt @@ -14,10 +14,17 @@ * limitations under the License. */ -package androidx.compose.foundation +@file:Suppress( + "RedundantVisibilityModifier", + "KotlinRedundantDiagnosticSuppress", + "KotlinConstantConditions", + "PropertyName", + "ConstPropertyName", + "PrivatePropertyName", + "NOTHING_TO_INLINE" +) -import kotlin.coroutines.cancellation.CancellationException +package androidx.collection -internal actual abstract class CorePlatformOptimizedCancellationException actual constructor( - message: String? -) : CancellationException(message) +// An empty array of floats +internal val EmptyFloatArray = FloatArray(0) diff --git a/core/src/commonMain/kotlin/androidx/collection/ObjectFloatMap.kt b/core/src/commonMain/kotlin/androidx/collection/ObjectFloatMap.kt new file mode 100644 index 0000000..45a676c --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/collection/ObjectFloatMap.kt @@ -0,0 +1,1157 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "RedundantVisibilityModifier", + "NOTHING_TO_INLINE" +) + +package androidx.collection + +import androidx.collection.internal.EMPTY_OBJECTS +import androidx.collection.internal.requirePrecondition +import kotlin.jvm.JvmField +import kotlin.jvm.JvmOverloads + +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +// DO NOT MAKE CHANGES to the kotlin source file. +// +// This file was generated from a template in the template directory. +// Make a change to the original template and run the generateCollections.sh script +// to ensure the change is available on all versions of the map. +// +// Note that there are 3 templates for maps, one for object-to-primitive, one +// for primitive-to-object and one for primitive-to-primitive. Also, the +// object-to-object is ScatterMap.kt, which doesn't have a template. +// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= + +// Default empty map to avoid allocations +private val EmptyObjectFloatMap = MutableObjectFloatMap(0) + +/** + * Returns an empty, read-only [ObjectFloatMap]. + */ +@Suppress("UNCHECKED_CAST") +public fun emptyObjectFloatMap(): ObjectFloatMap = + EmptyObjectFloatMap as ObjectFloatMap + +/** + * Returns an empty, read-only [ObjectFloatMap]. + */ +@Suppress("UNCHECKED_CAST") +public fun objectFloatMap(): ObjectFloatMap = + EmptyObjectFloatMap as ObjectFloatMap + +/** + * Returns a new [ObjectFloatMap] with only [key1] associated with [value1]. + */ +public fun objectFloatMapOf( + key1: K, + value1: Float +): ObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + } + +/** + * Returns a new [ObjectFloatMap] with only [key1] and [key2] associated with + * [value1] and [value2], respectively. + */ +public fun objectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, +): ObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + } + +/** + * Returns a new [ObjectFloatMap] with only [key1], [key2], and [key3] associated with + * [value1], [value2], and [value3], respectively. + */ +public fun objectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, + key3: K, + value3: Float, +): ObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + map[key3] = value3 + } + +/** + * Returns a new [ObjectFloatMap] with only [key1], [key2], [key3], and [key4] associated with + * [value1], [value2], [value3], and [value4], respectively. + */ +public fun objectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, + key3: K, + value3: Float, + key4: K, + value4: Float, +): ObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + map[key3] = value3 + map[key4] = value4 + } + +/** + * Returns a new [ObjectFloatMap] with only [key1], [key2], [key3], [key4], and [key5] associated + * with [value1], [value2], [value3], [value4], and [value5], respectively. + */ +public fun objectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, + key3: K, + value3: Float, + key4: K, + value4: Float, + key5: K, + value5: Float, +): ObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + map[key3] = value3 + map[key4] = value4 + map[key5] = value5 + } + +/** + * Returns a new empty [MutableObjectFloatMap]. + */ +public fun mutableObjectFloatMapOf(): MutableObjectFloatMap = MutableObjectFloatMap() + +/** + * Returns a new [MutableObjectFloatMap] with only [key1] associated with [value1]. + */ +public fun mutableObjectFloatMapOf( + key1: K, + value1: Float, +): MutableObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + } + +/** + * Returns a new [MutableObjectFloatMap] with only [key1] and [key2] associated with + * [value1] and [value2], respectively. + */ +public fun mutableObjectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, +): MutableObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + } + +/** + * Returns a new [MutableObjectFloatMap] with only [key1], [key2], and [key3] associated with + * [value1], [value2], and [value3], respectively. + */ +public fun mutableObjectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, + key3: K, + value3: Float, +): MutableObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + map[key3] = value3 + } + +/** + * Returns a new [MutableObjectFloatMap] with only [key1], [key2], [key3], and [key4] + * associated with [value1], [value2], [value3], and [value4], respectively. + */ +public fun mutableObjectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, + key3: K, + value3: Float, + key4: K, + value4: Float, +): MutableObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + map[key3] = value3 + map[key4] = value4 + } + +/** + * Returns a new [MutableObjectFloatMap] with only [key1], [key2], [key3], [key4], and [key5] + * associated with [value1], [value2], [value3], [value4], and [value5], respectively. + */ +public fun mutableObjectFloatMapOf( + key1: K, + value1: Float, + key2: K, + value2: Float, + key3: K, + value3: Float, + key4: K, + value4: Float, + key5: K, + value5: Float, +): MutableObjectFloatMap = + MutableObjectFloatMap().also { map -> + map[key1] = value1 + map[key2] = value2 + map[key3] = value3 + map[key4] = value4 + map[key5] = value5 + } + +/** + * [ObjectFloatMap] is a container with a [Map]-like interface for keys with + * reference types and [Float] primitives for values. + * + * The underlying implementation is designed to avoid allocations from boxing, + * and insertion, removal, retrieval, and iteration operations. Allocations + * may still happen on insertion when the underlying storage needs to grow to + * accommodate newly added entries to the table. In addition, this implementation + * minimizes memory usage by avoiding the use of separate objects to hold + * key/value pairs. + * + * This implementation makes no guarantee as to the order of the keys and + * values stored, nor does it make guarantees that the order remains constant + * over time. + * + * This implementation is not thread-safe: if multiple threads access this + * container concurrently, and one or more threads modify the structure of + * the map (insertion or removal for instance), the calling code must provide + * the appropriate synchronization. Multiple threads are safe to read from this + * map concurrently if no write is happening. + * + * This implementation is read-only and only allows data to be queried. A + * mutable implementation is provided by [MutableObjectFloatMap]. + * + * @see [MutableObjectFloatMap] + * @see ScatterMap + */ +public sealed class ObjectFloatMap { + // NOTE: Our arrays are marked internal to implement inlined forEach{} + // The backing array for the metadata bytes contains + // `capacity + 1 + ClonedMetadataCount` entries, including when + // the table is empty (see [EmptyGroup]). + @PublishedApi + @JvmField + internal var metadata: LongArray = EmptyGroup + + @PublishedApi + @JvmField + internal var keys: Array = EMPTY_OBJECTS + + @PublishedApi + @JvmField + internal var values: FloatArray = EmptyFloatArray + + // We use a backing field for capacity to avoid invokevirtual calls + // every time we need to look at the capacity + @Suppress("PropertyName") + @JvmField + internal var _capacity: Int = 0 + + /** + * Returns the number of key-value pairs that can be stored in this map + * without requiring internal storage reallocation. + */ + public val capacity: Int + get() = _capacity + + // We use a backing field for capacity to avoid invokevirtual calls + // every time we need to look at the size + @Suppress("PropertyName") + @JvmField + internal var _size: Int = 0 + + /** + * Returns the number of key-value pairs in this map. + */ + public val size: Int + get() = _size + + /** + * Returns `true` if this map has at least one entry. + */ + public fun any(): Boolean = _size != 0 + + /** + * Returns `true` if this map has no entries. + */ + public fun none(): Boolean = _size == 0 + + /** + * Indicates whether this map is empty. + */ + public fun isEmpty(): Boolean = _size == 0 + + /** + * Returns `true` if this map is not empty. + */ + public fun isNotEmpty(): Boolean = _size != 0 + + /** + * Returns the value corresponding to the given [key], or `null` if such + * a key is not present in the map. + * @throws NoSuchElementException when [key] is not found + */ + public operator fun get(key: K): Float { + val index = findKeyIndex(key) + if (index < 0) { + throw NoSuchElementException("There is no key $key in the map") + } + return values[index] + } + + /** + * Returns the value to which the specified [key] is mapped, + * or [defaultValue] if this map contains no mapping for the key. + */ + public fun getOrDefault(key: K, defaultValue: Float): Float { + val index = findKeyIndex(key) + if (index >= 0) { + return values[index] + } + return defaultValue + } + + /** + * Returns the value for the given [key] if the value is present + * and not null. Otherwise, returns the result of the [defaultValue] + * function. + */ + public inline fun getOrElse(key: K, defaultValue: () -> Float): Float { + val index = findKeyIndex(key) + if (index >= 0) { + return values[index] + } + return defaultValue() + } + + /** + * Iterates over every key/value pair stored in this map by invoking + * the specified [block] lambda. + */ + @PublishedApi + internal inline fun forEachIndexed(block: (index: Int) -> Unit) { + val m = metadata + val lastIndex = m.size - 2 // We always have 0 or at least 2 entries + + for (i in 0..lastIndex) { + var slot = m[i] + if (slot.maskEmptyOrDeleted() != BitmaskMsb) { + // Branch-less if (i == lastIndex) 7 else 8 + // i - lastIndex returns a negative value when i < lastIndex, + // so 1 is set as the MSB. By inverting and shifting we get + // 0 when i < lastIndex, 1 otherwise. + val bitCount = 8 - ((i - lastIndex).inv() ushr 31) + for (j in 0 until bitCount) { + if (isFull(slot and 0xFFL)) { + val index = (i shl 3) + j + block(index) + } + slot = slot shr 8 + } + if (bitCount != 8) return + } + } + } + + /** + * Iterates over every key/value pair stored in this map by invoking + * the specified [block] lambda. + */ + public inline fun forEach(block: (key: K, value: Float) -> Unit) { + val k = keys + val v = values + + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + block(k[index] as K, v[index]) + } + } + + /** + * Iterates over every key stored in this map by invoking the specified + * [block] lambda. + */ + public inline fun forEachKey(block: (key: K) -> Unit) { + val k = keys + + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + block(k[index] as K) + } + } + + /** + * Iterates over every value stored in this map by invoking the specified + * [block] lambda. + */ + public inline fun forEachValue(block: (value: Float) -> Unit) { + val v = values + + forEachIndexed { index -> + block(v[index]) + } + } + + /** + * Returns true if all entries match the given [predicate]. + */ + public inline fun all(predicate: (K, Float) -> Boolean): Boolean { + forEach { key, value -> + if (!predicate(key, value)) return false + } + return true + } + + /** + * Returns true if at least one entry matches the given [predicate]. + */ + public inline fun any(predicate: (K, Float) -> Boolean): Boolean { + forEach { key, value -> + if (predicate(key, value)) return true + } + return false + } + + /** + * Returns the number of entries in this map. + */ + public fun count(): Int = size + + /** + * Returns the number of entries matching the given [predicate]. + */ + public inline fun count(predicate: (K, Float) -> Boolean): Int { + var count = 0 + forEach { key, value -> + if (predicate(key, value)) count++ + } + return count + } + + /** + * Returns true if the specified [key] is present in this hash map, false + * otherwise. + */ + public operator fun contains(key: K): Boolean = findKeyIndex(key) >= 0 + + /** + * Returns true if the specified [key] is present in this hash map, false + * otherwise. + */ + public fun containsKey(key: K): Boolean = findKeyIndex(key) >= 0 + + /** + * Returns true if the specified [value] is present in this hash map, false + * otherwise. + */ + public fun containsValue(value: Float): Boolean { + forEachValue { v -> + if (value == v) return true + } + return false + } + + /** + * Creates a String from the entries, separated by [separator] and using [prefix] before + * and [postfix] after, if supplied. + * + * When a non-negative value of [limit] is provided, a maximum of [limit] items are used + * to generate the string. If the collection holds more than [limit] items, the string + * is terminated with [truncated]. + */ + @JvmOverloads + public fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", // I know this should be suffix, but this is kotlin's name + limit: Int = -1, + truncated: CharSequence = "...", + ): String = buildString { + append(prefix) + var index = 0 + this@ObjectFloatMap.forEach { key, value -> + if (index == limit) { + append(truncated) + return@buildString + } + if (index != 0) { + append(separator) + } + append(key) + append('=') + append(value) + index++ + } + append(postfix) + } + + /** + * Creates a String from the entries, separated by [separator] and using [prefix] before + * and [postfix] after, if supplied. Each entry is created with [transform]. + * + * When a non-negative value of [limit] is provided, a maximum of [limit] items are used + * to generate the string. If the collection holds more than [limit] items, the string + * is terminated with [truncated]. + */ + @JvmOverloads + public inline fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", // I know this should be suffix, but this is kotlin's name + limit: Int = -1, + truncated: CharSequence = "...", + crossinline transform: (key: K, value: Float) -> CharSequence + ): String = buildString { + append(prefix) + var index = 0 + this@ObjectFloatMap.forEach { key, value -> + if (index == limit) { + append(truncated) + return@buildString + } + if (index != 0) { + append(separator) + } + append(transform(key, value)) + index++ + } + append(postfix) + } + + /** + * Returns the hash code value for this map. The hash code the sum of the hash + * codes of each key/value pair. + */ + public override fun hashCode(): Int { + var hash = 0 + + forEach { key, value -> + hash += key.hashCode() xor value.hashCode() + } + + return hash + } + + /** + * Compares the specified object [other] with this hash map for equality. + * The two objects are considered equal if [other]: + * - Is a [ObjectFloatMap] + * - Has the same [size] as this map + * - Contains key/value pairs equal to this map's pair + */ + public override fun equals(other: Any?): Boolean { + if (other === this) { + return true + } + + if (other !is ObjectFloatMap<*>) { + return false + } + if (other.size != size) { + return false + } + + @Suppress("UNCHECKED_CAST") + val o = other as ObjectFloatMap + + forEach { key, value -> + if (value != o[key]) { + return false + } + } + + return true + } + + /** + * Returns a string representation of this map. The map is denoted in the + * string by the `{}`. Each key/value pair present in the map is represented + * inside '{}` by a substring of the form `key=value`, and pairs are + * separated by `, `. + */ + public override fun toString(): String { + if (isEmpty()) { + return "{}" + } + + val s = StringBuilder().append('{') + var i = 0 + forEach { key, value -> + s.append(if (key === this) "(this)" else key) + s.append("=") + s.append(value) + i++ + if (i < _size) { + s.append(',').append(' ') + } + } + + return s.append('}').toString() + } + + /** + * Scans the hash table to find the index in the backing arrays of the + * specified [key]. Returns -1 if the key is not present. + */ + @PublishedApi + internal fun findKeyIndex(key: K): Int { + val hash = hash(key) + val hash2 = h2(hash) + + val probeMask = _capacity + var probeOffset = h1(hash) and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + var m = g.match(hash2) + while (m.hasNext()) { + val index = (probeOffset + m.get()) and probeMask + if (keys[index] == key) { + return index + } + m = m.next() + } + + if (g.maskEmpty() != 0L) { + break + } + + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + + return -1 + } +} + +/** + * [MutableObjectFloatMap] is a container with a [MutableMap]-like interface for keys with + * reference types and [Float] primitives for values. + * + * The underlying implementation is designed to avoid allocations from boxing, + * and insertion, removal, retrieval, and iteration operations. Allocations + * may still happen on insertion when the underlying storage needs to grow to + * accommodate newly added entries to the table. In addition, this implementation + * minimizes memory usage by avoiding the use of separate objects to hold + * key/value pairs. + * + * This implementation is not thread-safe: if multiple threads access this + * container concurrently, and one or more threads modify the structure of + * the map (insertion or removal for instance), the calling code must provide + * the appropriate synchronization. Multiple threads are safe to read from this + * map concurrently if no write is happening. + * + * @constructor Creates a new [MutableObjectFloatMap] + * @param initialCapacity The initial desired capacity for this container. + * the container will honor this value by guaranteeing its internal structures + * can hold that many entries without requiring any allocations. The initial + * capacity can be set to 0. + * + * @see MutableScatterMap + */ +public class MutableObjectFloatMap( + initialCapacity: Int = DefaultScatterCapacity +) : ObjectFloatMap() { + // Number of entries we can add before we need to grow + private var growthLimit = 0 + + init { + requirePrecondition(initialCapacity >= 0) { "Capacity must be a positive value." } + initializeStorage(unloadedCapacity(initialCapacity)) + } + + private fun initializeStorage(initialCapacity: Int) { + val newCapacity = if (initialCapacity > 0) { + // Since we use longs for storage, our capacity is never < 7, enforce + // it here. We do have a special case for 0 to create small empty maps + maxOf(7, normalizeCapacity(initialCapacity)) + } else { + 0 + } + _capacity = newCapacity + initializeMetadata(newCapacity) + keys = arrayOfNulls(newCapacity) + values = FloatArray(newCapacity) + } + + private fun initializeMetadata(capacity: Int) { + metadata = if (capacity == 0) { + EmptyGroup + } else { + // Round up to the next multiple of 8 and find how many longs we need + val size = (((capacity + 1 + ClonedMetadataCount) + 7) and 0x7.inv()) shr 3 + LongArray(size).apply { + fill(AllEmpty) + } + } + writeRawMetadata(metadata, capacity, Sentinel) + initializeGrowth() + } + + private fun initializeGrowth() { + growthLimit = loadedCapacity(capacity) - _size + } + + /** + * Returns the value to which the specified [key] is mapped, + * if the value is present in the map and not `null`. Otherwise, + * calls `defaultValue()` and puts the result in the map associated + * with [key]. + */ + public inline fun getOrPut(key: K, defaultValue: () -> Float): Float { + val index = findKeyIndex(key) + if (index >= 0) { + return values[index] + } + val value = defaultValue() + set(key, value) + return value + } + + /** + * Creates a new mapping from [key] to [value] in this map. If [key] is + * already present in the map, the association is modified and the previously + * associated value is replaced with [value]. If [key] is not present, a new + * entry is added to the map, which may require to grow the underlying storage + * and cause allocations. + */ + public operator fun set(key: K, value: Float) { + var index = findIndex(key) + if (index < 0) index = index.inv() + keys[index] = key + values[index] = value + } + + /** + * Creates a new mapping from [key] to [value] in this map. If [key] is + * already present in the map, the association is modified and the previously + * associated value is replaced with [value]. If [key] is not present, a new + * entry is added to the map, which may require to grow the underlying storage + * and cause allocations. + */ + public fun put(key: K, value: Float) { + set(key, value) + } + + /** + * Creates a new mapping from [key] to [value] in this map. If [key] is + * already present in the map, the association is modified and the previously + * associated value is replaced with [value]. If [key] is not present, a new + * entry is added to the map, which may require to grow the underlying storage + * and cause allocations. + * + * @return value previously associated with [key] or [default] if key was not present. + */ + public fun put(key: K, value: Float, default: Float): Float { + var index = findIndex(key) + var previous = default + if (index < 0) { + index = index.inv() + } else { + previous = values[index] + } + keys[index] = key + values[index] = value + + return previous + } + + /** + * Puts all the key/value mappings in the [from] map into this map. + */ + public fun putAll(from: ObjectFloatMap) { + from.forEach { key, value -> + this[key] = value + } + } + + /** + * Puts all the key/value mappings in the [from] map into this map. + */ + public inline operator fun plusAssign(from: ObjectFloatMap): Unit = putAll(from) + + /** + * Removes the specified [key] and its associated value from the map. + */ + public fun remove(key: K) { + val index = findKeyIndex(key) + if (index >= 0) { + removeValueAt(index) + } + } + + /** + * Removes the specified [key] and its associated value from the map if the + * associated value equals [value]. Returns whether the removal happened. + */ + public fun remove(key: K, value: Float): Boolean { + val index = findKeyIndex(key) + if (index >= 0) { + if (values[index] == value) { + removeValueAt(index) + return true + } + } + return false + } + + /** + * Removes any mapping for which the specified [predicate] returns true. + */ + public inline fun removeIf(predicate: (K, Float) -> Boolean) { + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + if (predicate(keys[index] as K, values[index])) { + removeValueAt(index) + } + } + } + + /** + * Removes the specified [key] and its associated value from the map. + */ + public inline operator fun minusAssign(key: K) { + remove(key) + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(@Suppress("ArrayReturn") keys: Array) { + for (key in keys) { + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(keys: Iterable) { + for (key in keys) { + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(keys: Sequence) { + for (key in keys) { + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(keys: ScatterSet) { + keys.forEach { key -> + remove(key) + } + } + + @PublishedApi + internal fun removeValueAt(index: Int) { + _size -= 1 + + // TODO: We could just mark the entry as empty if there's a group + // window around this entry that was already empty + writeMetadata(metadata, _capacity, index, Deleted) + keys[index] = null + } + + /** + * Removes all mappings from this map. + */ + public fun clear() { + _size = 0 + if (metadata !== EmptyGroup) { + metadata.fill(AllEmpty) + writeRawMetadata(metadata, _capacity, Sentinel) + } + keys.fill(null, 0, _capacity) + initializeGrowth() + } + + /** + * Scans the hash table to find the index at which we can store a value + * for the give [key]. If the key already exists in the table, its index + * will be returned, otherwise the index of an empty slot will be returned. + * Calling this function may cause the internal storage to be reallocated + * if the table is full. + */ + private fun findIndex(key: K): Int { + val hash = hash(key) + val hash1 = h1(hash) + val hash2 = h2(hash) + + val probeMask = _capacity + var probeOffset = hash1 and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + var m = g.match(hash2) + while (m.hasNext()) { + val index = (probeOffset + m.get()) and probeMask + if (keys[index] == key) { + return index + } + m = m.next() + } + + if (g.maskEmpty() != 0L) { + break + } + + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + + var index = findFirstAvailableSlot(hash1) + if (growthLimit == 0 && !isDeleted(metadata, index)) { + adjustStorage() + index = findFirstAvailableSlot(hash1) + } + + _size += 1 + growthLimit -= if (isEmpty(metadata, index)) 1 else 0 + writeMetadata(metadata, _capacity, index, hash2.toLong()) + + return index.inv() + } + + /** + * Finds the first empty or deleted slot in the table in which we can + * store a value without resizing the internal storage. + */ + private fun findFirstAvailableSlot(hash1: Int): Int { + val probeMask = _capacity + var probeOffset = hash1 and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + val m = g.maskEmptyOrDeleted() + if (m != 0L) { + return (probeOffset + m.lowestBitSet()) and probeMask + } + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + } + + /** + * Trims this [MutableObjectFloatMap]'s storage so it is sized appropriately + * to hold the current mappings. + * + * Returns the number of empty entries removed from this map's storage. + * Returns be 0 if no trimming is necessary or possible. + */ + public fun trim(): Int { + val previousCapacity = _capacity + val newCapacity = normalizeCapacity(unloadedCapacity(_size)) + if (newCapacity < previousCapacity) { + resizeStorage(newCapacity) + return previousCapacity - _capacity + } + return 0 + } + + /** + * Grow internal storage if necessary. This function can instead opt to + * remove deleted entries from the table to avoid an expensive reallocation + * of the underlying storage. This "rehash in place" occurs when the + * current size is <= 25/32 of the table capacity. The choice of 25/32 is + * detailed in the implementation of abseil's `raw_hash_set`. + */ + private fun adjustStorage() { + if (_capacity > GroupWidth && _size.toULong() * 32UL <= _capacity.toULong() * 25UL) { + dropDeletes() + } else { + resizeStorage(nextCapacity(_capacity)) + } + } + + private fun dropDeletes() { + val metadata = metadata + val capacity = _capacity + val keys = keys + val values = values + + // Converts Sentinel and Deleted to Empty, and Full to Deleted + convertMetadataForCleanup(metadata, capacity) + + var swapIndex = -1 + var index = 0 + + // Drop deleted items and re-hashes surviving entries + while (index != capacity) { + var m = readRawMetadata(metadata, index) + // Formerly Deleted entry, we can use it as a swap spot + if (m == Empty) { + swapIndex = index + index++ + continue + } + + // Formerly Full entries are now marked Deleted. If we see an + // entry that's not marked Deleted, we can ignore it completely + if (m != Deleted) { + index++ + continue + } + + val hash = hash(keys[index]) + val hash1 = h1(hash) + val targetIndex = findFirstAvailableSlot(hash1) + + // Test if the current index (i) and the new index (targetIndex) fall + // within the same group based on the hash. If the group doesn't change, + // we don't move the entry + val probeOffset = hash1 and capacity + val newProbeIndex = ((targetIndex - probeOffset) and capacity) / GroupWidth + val oldProbeIndex = ((index - probeOffset) and capacity) / GroupWidth + + if (newProbeIndex == oldProbeIndex) { + val hash2 = h2(hash) + writeRawMetadata(metadata, index, hash2.toLong()) + + // Copies the metadata into the clone area + metadata[metadata.lastIndex] = + (Empty shl 56) or (metadata[0] and 0x00ffffff_ffffffffL) + + index++ + continue + } + + m = readRawMetadata(metadata, targetIndex) + if (m == Empty) { + // The target is empty so we can transfer directly + val hash2 = h2(hash) + writeRawMetadata(metadata, targetIndex, hash2.toLong()) + writeRawMetadata(metadata, index, Empty) + + keys[targetIndex] = keys[index] + keys[index] = null + + values[targetIndex] = values[index] + values[index] = 0f + + swapIndex = index + } else /* m == Deleted */ { + // The target isn't empty so we use an empty slot denoted by + // swapIndex to perform the swap + val hash2 = h2(hash) + writeRawMetadata(metadata, targetIndex, hash2.toLong()) + + if (swapIndex == -1) { + swapIndex = findEmptySlot(metadata, index + 1, capacity) + } + + keys[swapIndex] = keys[targetIndex] + keys[targetIndex] = keys[index] + keys[index] = keys[swapIndex] + + values[swapIndex] = values[targetIndex] + values[targetIndex] = values[index] + values[index] = values[swapIndex] + + // Since we exchanged two slots we must repeat the process with + // element we just moved in the current location + index-- + } + + // Copies the metadata into the clone area + metadata[metadata.lastIndex] = (Empty shl 56) or (metadata[0] and 0x00ffffff_ffffffffL) + + index++ + } + + initializeGrowth() + } + + private fun resizeStorage(newCapacity: Int) { + val previousMetadata = metadata + val previousKeys = keys + val previousValues = values + val previousCapacity = _capacity + + initializeStorage(newCapacity) + + val newMetadata = metadata + val newKeys = keys + val newValues = values + val capacity = _capacity + + for (i in 0 until previousCapacity) { + if (isFull(previousMetadata, i)) { + val previousKey = previousKeys[i] + val hash = hash(previousKey) + val index = findFirstAvailableSlot(h1(hash)) + + writeMetadata(newMetadata, capacity, index, h2(hash).toLong()) + newKeys[index] = previousKey + newValues[index] = previousValues[i] + } + } + } + + /** + * Writes the "H2" part of an entry into the metadata array at the specified + * [index]. The index must be a valid index. This function ensures the + * metadata is also written in the clone area at the end. + */ + private inline fun writeMetadata(index: Int, value: Long) { + val m = metadata + writeRawMetadata(m, index, value) + + // Mirroring + val c = _capacity + val cloneIndex = ((index - ClonedMetadataCount) and c) + + (ClonedMetadataCount and c) + writeRawMetadata(m, cloneIndex, value) + } +} diff --git a/core/src/commonMain/kotlin/androidx/collection/ScatterMap.kt b/core/src/commonMain/kotlin/androidx/collection/ScatterMap.kt new file mode 100644 index 0000000..6e7d7af --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/collection/ScatterMap.kt @@ -0,0 +1,1979 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "RedundantVisibilityModifier", + "KotlinRedundantDiagnosticSuppress", + "KotlinConstantConditions", + "PropertyName", + "ConstPropertyName", + "PrivatePropertyName", + "NOTHING_TO_INLINE" +) + +package androidx.collection + +import androidx.collection.internal.EMPTY_OBJECTS +import androidx.collection.internal.requirePrecondition +import kotlin.jvm.JvmField +import kotlin.jvm.JvmOverloads +import kotlin.math.max + +// A "flat" hash map based on abseil's flat_hash_map +// (see https://abseil.io/docs/cpp/guides/container). Unlike its C++ +// equivalent, this hash map doesn't (and cannot) store the keys and values +// directly inside a table. Instead the references and keys are stored in +// 2 separate tables. The implementation could be made "flatter" by storing +// both keys and values in the same array but this yields no improvement. +// +// The main design goal of this container is to provide a generic, cache- +// friendly, *allocation free* hash map, with performance on par with +// LinkedHashMap to act as a suitable replacement for the common +// mutableMapOf() in Kotlin. +// +// The implementation is very similar, and is based, as the name suggests, +// on a flat table of values. To understand the implementation, let's first +// define the terminology used throughout this file: +// +// - Slot +// An entry in the backing table; in practice a slot is a pair of +// (key, value) stored in two separate allocations. +// - Metadata +// Indicates the state of a slot (available, etc. see below) but +// can also store part of the slot's hash. +// - Group +// Metadata for multiple slots that can be manipulated as a unit to +// speed up processing. +// +// To quickly and efficiently find any given slot, the implementation uses +// groups to compare up to 8 entries at a time. To achieve this, we use +// open-addressing probing quadratic probing +// (https://en.wikipedia.org/wiki/Quadratic_probing). See "A note on +// probing" down below for more information. +// +// The table's memory layout is organized around 3 arrays: +// +// - metadata +// An array of metadata bytes, encoded as a LongArray (see below). +// The size of this array depends on capacity, but is smaller since +// the array encodes 8 metadata per Long. There is also padding at +// the end to permit branchless probing. +// - keys +// Holds references to the key stored in the map. An index i in +// this array maps to the corresponding values in the values array. +// This array always has the same size as the capacity of the map. +// - values +// Holds references to the key stored in the map. An index i in +// this array maps to the corresponding values in the keys array +// This array always has the same size as the capacity of the map. +// +// A key's hash code is separated into two distinct hashes: +// +// - H1: the hash code's 25 most significant bits +// - H2: the hash code's 7 least significant bits +// +// H1 is used as an index into the slots, and a starting point for a probe +// whenever we need to seek an entry in the table. H2 is used to quickly +// filter out slots when looking for a specific key in the table. +// +// While H1 is used to initiate a probing sequence, it is never stored in +// the table. H2 is however stored in the metadata of a slot. The metadata +// for any given slot is a single byte, which can have one of four states: +// +// - Empty: unused slot +// - Deleted: previously used slot +// - Full: used slot +// - Sentinel: marker to avoid branching, used to stop iterations +// +// They have the following bit patterns: +// +// Empty: 1 0 0 0 0 0 0 0 +// Deleted: 1 1 1 1 1 1 1 0 +// Full: 0 h h h h h h h // h represents the lower 7 hash bits +// Sentinel: 1 1 1 1 1 1 1 1 +// +// Insertions, reads, removals, and replacements all need to perform the +// same basic operation: finding a specific slot in the table. This `find` +// operation works like this: +// +// - Compute H1 from the key's hash code +// - Initialize a probe sequence from H1, which will potentially visit +// every group in the map (but usually stops at the first one) +// - For each probe offset, select an entire group (8 entries) and find +// candidate slots in that group. This means finding slots with a +// matching H2 hash. We then iterate over the matching slots and compare +// the slot's key to the find's key. If we have a final match, we know +// the index of the key/value pair in the table. If there is no match +// and the entire group is empty, the key does not exist in the table. +// +// Matching a Group with H2 ensures that one of the matching slots is +// likely to hold the same key as the one we are looking for. It also lets +// us quickly skip entire chunks of the map (for instance during iteration +// if a Group contains only empty slots, we can ignore it entirely). +// +// Since the metadata of a slot is made of a single byte, we could use +// a ByteArray instead of a LongArray. However, using a LongArray makes +// constructing a group cheaper and guarantees aligned reads. As a result +// we use a form of virtual addressing: when looking for a group starting +// at index 3 for instance, we do not fetch the 4th entry in the array of +// metadata, but instead find the Long that holds the 4th byte and create +// a Group of 8 bytes starting from that byte. The details are explained +// below in the group() function. +// +// ** A note on probing ** +// +// A probe is a virtual construct used to iterate over the groups in the +// hash table in some interesting order. To probe the tables, we must +// initiate probing using the hash at which we want to start, using a +// suitable mask, in our case the table's capacity. +// +// The sequence is a triangular progression of the form: +// +// `p(i) = GroupWidth * (i ^ 2 + i) / 2 + hash (mod mask + 1)` +// +// The first few entries in the metadata table are mirrored at the end of +// the table so when we inspect those candidates we must make sure to not +// use their offset directly but instead the "wrap around" values, hence +// the `mask + 1` modulo. +// +// This probe sequence visits every group exactly once if the number of +// groups is a power of two, since `(i ^ 2 + i) / 2` is a bijection in +// `Z / (2 ^ m)`. See https://en.wikipedia.org/wiki/Quadratic_probing +// +// Reference: +// Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step +// 2017, Matt Kulukundis, https://www.youtube.com/watch?v=ncHmEUmJZf4 + +// Indicates that all the slot in a [Group] are empty +// 0x8080808080808080UL, see explanation in [BitmaskMsb] +internal const val AllEmpty = -0x7f7f7f7f_7f7f7f80L + +internal const val Empty = 0b10000000L +internal const val Deleted = 0b11111110L + +// Used to mark the end of the actual storage, used to end iterations +@PublishedApi +internal const val Sentinel: Long = 0b11111111L + +// The number of entries depends on [GroupWidth]. Since our group width +// is fixed to 8 currently, we add 7 entries after the sentinel. To +// satisfy the case of a 0 capacity map, we also add another entry full +// of sentinels. Since our lookups always fetch 2 longs from the array, +// we make sure we have enough +@JvmField +internal val EmptyGroup = + longArrayOf( + // NOTE: the first byte in the array's logical order is in the LSB + -0x7f7f7f7f_7f7f7f01L, // Sentinel, Empty, Empty... or 0xFF80808080808080UL + -1L // 0xFFFFFFFFFFFFFFFFUL + ) + +// Width of a group, in bytes. Since we can only use types as large as +// Long we must fit our metadata bytes in a 64-bit word or smaller, which +// means we can only store up to 8 slots in a group. Ideally we could use +// 128-bit data types to benefit from NEON/SSE instructions and manipulate +// groups of 16 slots at a time. +internal const val GroupWidth = 8 + +// A group is made of 8 metadata, or 64 bits +internal typealias Group = Long + +// Number of metadata present both at the beginning and at the end of +// the metadata array so we can use a [GroupWidth] probing window from +// any index in the table. +internal const val ClonedMetadataCount = GroupWidth - 1 + +// Capacity to use as the first bump when capacity is initially 0 +// We choose 6 so that the "unloaded" capacity maps to 7 +internal const val DefaultScatterCapacity = 6 + +// Default empty map to avoid allocations +private val EmptyScatterMap = MutableScatterMap(0) + +/** + * Returns an empty, read-only [ScatterMap]. + */ +@Suppress("UNCHECKED_CAST") +public fun emptyScatterMap(): ScatterMap = EmptyScatterMap as ScatterMap + +/** + * Returns a new [MutableScatterMap]. + */ +public fun mutableScatterMapOf(): MutableScatterMap = MutableScatterMap() + +/** + * Returns a new [MutableScatterMap] with the specified contents, given as + * a list of pairs where the first component is the key and the second + * is the value. If multiple pairs have the same key, the resulting map + * will contain the value from the last of those pairs. + */ +public fun mutableScatterMapOf(vararg pairs: Pair): MutableScatterMap = + MutableScatterMap(pairs.size).apply { + putAll(pairs) + } + +/** + * [ScatterMap] is a container with a [Map]-like interface based on a flat + * hash table implementation (the key/value mappings are not stored by nodes + * but directly into arrays). The underlying implementation is designed to avoid + * all allocations on insertion, removal, retrieval, and iteration. Allocations + * may still happen on insertion when the underlying storage needs to grow to + * accommodate newly added entries to the table. In addition, this implementation + * minimizes memory usage by avoiding the use of separate objects to hold + * key/value pairs. + * + * This implementation makes no guarantee as to the order of the keys and + * values stored, nor does it make guarantees that the order remains constant + * over time. + * + * This implementation is not thread-safe: if multiple threads access this + * container concurrently, and one or more threads modify the structure of + * the map (insertion or removal for instance), the calling code must provide + * the appropriate synchronization. Multiple threads are safe to read from this + * map concurrently if no write is happening. + * + * This implementation is read-only and only allows data to be queried. A + * mutable implementation is provided by [MutableScatterMap]. + * + * **Note**: when a [Map] is absolutely necessary, you can use the method + * [asMap] to create a thin wrapper around a [ScatterMap]. Please refer to + * [asMap] for more details and caveats. + * + * **ScatterMap and SimpleArrayMap**: like [SimpleArrayMap], + * [ScatterMap]/[MutableScatterMap] is designed to avoid the allocation of + * extra objects when inserting new entries in the map. However, the + * implementation of [ScatterMap]/[MutableScatterMap] offers better performance + * characteristics compared to [SimpleArrayMap] and is thus generally + * preferable. If memory usage is a concern, [SimpleArrayMap] automatically + * shrinks its storage to avoid using more memory than necessary. You can + * also control memory usage with [MutableScatterMap] by manually calling + * [MutableScatterMap.trim]. + * + * @see [MutableScatterMap] + */ +public sealed class ScatterMap { + // NOTE: Our arrays are marked internal to implement inlined forEach{} + // The backing array for the metadata bytes contains + // `capacity + 1 + ClonedMetadataCount` entries, including when + // the table is empty (see [EmptyGroup]). + @PublishedApi + @JvmField + internal var metadata: LongArray = EmptyGroup + + @PublishedApi + @JvmField + internal var keys: Array = EMPTY_OBJECTS + + @PublishedApi + @JvmField + internal var values: Array = EMPTY_OBJECTS + + // We use a backing field for capacity to avoid invokevirtual calls + // every time we need to look at the capacity + @JvmField + internal var _capacity: Int = 0 + + /** + * Returns the number of key-value pairs that can be stored in this map + * without requiring internal storage reallocation. + */ + public val capacity: Int + get() = _capacity + + // We use a backing field for capacity to avoid invokevirtual calls + // every time we need to look at the size + @JvmField + internal var _size: Int = 0 + + /** + * Returns the number of key-value pairs in this map. + */ + public val size: Int + get() = _size + + /** + * Returns `true` if this map has at least one entry. + */ + public fun any(): Boolean = _size != 0 + + /** + * Returns `true` if this map has no entries. + */ + public fun none(): Boolean = _size == 0 + + /** + * Indicates whether this map is empty. + */ + public fun isEmpty(): Boolean = _size == 0 + + /** + * Returns `true` if this map is not empty. + */ + public fun isNotEmpty(): Boolean = _size != 0 + + /** + * Returns the value corresponding to the given [key], or `null` if such + * a key is not present in the map. + */ + public operator fun get(key: K): V? { + val index = findKeyIndex(key) + @Suppress("UNCHECKED_CAST") + return if (index >= 0) values[index] as V? else null + } + + /** + * Returns the value to which the specified [key] is mapped, + * or [defaultValue] if this map contains no mapping for the key. + */ + public fun getOrDefault(key: K, defaultValue: V): V { + val index = findKeyIndex(key) + if (index >= 0) { + @Suppress("UNCHECKED_CAST") + return values[index] as V + } + return defaultValue + } + + /** + * Returns the value for the given [key] if the value is present + * and not null. Otherwise, returns the result of the [defaultValue] + * function. + */ + public inline fun getOrElse(key: K, defaultValue: () -> V): V { + return get(key) ?: defaultValue() + } + + /** + * Iterates over every key/value pair stored in this map by invoking + * the specified [block] lambda. + */ + @PublishedApi + internal inline fun forEachIndexed(block: (index: Int) -> Unit) { + val m = metadata + val lastIndex = m.size - 2 // We always have 0 or at least 2 entries + + for (i in 0..lastIndex) { + var slot = m[i] + if (slot.maskEmptyOrDeleted() != BitmaskMsb) { + // Branch-less if (i == lastIndex) 7 else 8 + // i - lastIndex returns a negative value when i < lastIndex, + // so 1 is set as the MSB. By inverting and shifting we get + // 0 when i < lastIndex, 1 otherwise. + val bitCount = 8 - ((i - lastIndex).inv() ushr 31) + for (j in 0 until bitCount) { + if (isFull(slot and 0xffL)) { + val index = (i shl 3) + j + block(index) + } + slot = slot shr 8 + } + if (bitCount != 8) return + } + } + } + + /** + * Iterates over every key/value pair stored in this map by invoking + * the specified [block] lambda. + */ + public inline fun forEach(block: (key: K, value: V) -> Unit) { + val k = keys + val v = values + + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + block(k[index] as K, v[index] as V) + } + } + + /** + * Iterates over every key stored in this map by invoking the specified + * [block] lambda. + */ + public inline fun forEachKey(block: (key: K) -> Unit) { + val k = keys + + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + block(k[index] as K) + } + } + + /** + * Iterates over every value stored in this map by invoking the specified + * [block] lambda. + */ + public inline fun forEachValue(block: (value: V) -> Unit) { + val v = values + + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + block(v[index] as V) + } + } + + /** + * Returns true if all entries match the given [predicate]. + */ + public inline fun all(predicate: (K, V) -> Boolean): Boolean { + forEach { key, value -> + if (!predicate(key, value)) return false + } + return true + } + + /** + * Returns true if at least one entry matches the given [predicate]. + */ + public inline fun any(predicate: (K, V) -> Boolean): Boolean { + forEach { key, value -> + if (predicate(key, value)) return true + } + return false + } + + /** + * Returns the number of entries in this map. + */ + public fun count(): Int = size + + /** + * Returns the number of entries matching the given [predicate]. + */ + public inline fun count(predicate: (K, V) -> Boolean): Int { + var count = 0 + forEach { key, value -> + if (predicate(key, value)) count++ + } + return count + } + + /** + * Returns true if the specified [key] is present in this hash map, false + * otherwise. + */ + public operator fun contains(key: K): Boolean = findKeyIndex(key) >= 0 + + /** + * Returns true if the specified [key] is present in this hash map, false + * otherwise. + */ + public fun containsKey(key: K): Boolean = findKeyIndex(key) >= 0 + + /** + * Returns true if the specified [value] is present in this hash map, false + * otherwise. + */ + public fun containsValue(value: V): Boolean { + forEachValue { v -> + if (value == v) return true + } + return false + } + + /** + * Creates a String from the elements separated by [separator] and using [prefix] before + * and [postfix] after, if supplied. + * + * When a non-negative value of [limit] is provided, a maximum of [limit] items are used + * to generate the string. If the collection holds more than [limit] items, the string + * is terminated with [truncated]. + * + * [transform] may be supplied to convert each element to a custom String. + */ + @JvmOverloads + public fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", // I know this should be suffix, but this is kotlin's name + limit: Int = -1, + truncated: CharSequence = "...", + transform: ((key: K, value: V) -> CharSequence)? = null + ): String = buildString { + append(prefix) + var index = 0 + this@ScatterMap.forEach { key, value -> + if (index == limit) { + append(truncated) + return@buildString + } + if (index != 0) { + append(separator) + } + if (transform == null) { + append(key) + append('=') + append(value) + } else { + append(transform(key, value)) + } + index++ + } + append(postfix) + } + + /** + * Returns the hash code value for this map. The hash code the sum of the hash + * codes of each key/value pair. + */ + public override fun hashCode(): Int { + var hash = 0 + + forEach { key, value -> + hash += key.hashCode() xor value.hashCode() + } + + return hash + } + + /** + * Compares the specified object [other] with this hash map for equality. + * The two objects are considered equal if [other]: + * - Is a [ScatterMap] + * - Has the same [size] as this map + * - Contains key/value pairs equal to this map's pair + */ + public override fun equals(other: Any?): Boolean { + if (other === this) { + return true + } + + if (other !is ScatterMap<*, *>) { + return false + } + if (other.size != size) { + return false + } + + @Suppress("UNCHECKED_CAST") + val o = other as ScatterMap + + forEach { key, value -> + if (value == null) { + if (o[key] != null || !o.containsKey(key)) { + return false + } + } else if (value != o[key]) { + return false + } + } + + return true + } + + /** + * Returns a string representation of this map. The map is denoted in the + * string by the `{}`. Each key/value pair present in the map is represented + * inside '{}` by a substring of the form `key=value`, and pairs are + * separated by `, `. + */ + public override fun toString(): String { + if (isEmpty()) { + return "{}" + } + + val s = StringBuilder().append('{') + var i = 0 + forEach { key, value -> + s.append(if (key === this) "(this)" else key) + s.append("=") + s.append(if (value === this) "(this)" else value) + i++ + if (i < _size) { + s.append(',').append(' ') + } + } + + return s.append('}').toString() + } + + internal fun asDebugString(): String = buildString { + append('{') + append("metadata=[") + for (i in 0 until capacity) { + when (val metadata = readRawMetadata(metadata, i)) { + Empty -> append("Empty") + Deleted -> append("Deleted") + else -> append(metadata) + } + append(", ") + } + append("], ") + append("keys=[") + for (i in keys.indices) { + append(keys[i]) + append(", ") + } + append("], ") + append("values=[") + for (i in values.indices) { + append(values[i]) + append(", ") + } + append("]") + append('}') + } + + /** + * Scans the hash table to find the index in the backing arrays of the + * specified [key]. Returns -1 if the key is not present. + */ + internal inline fun findKeyIndex(key: K): Int { + val hash = hash(key) + val hash2 = h2(hash) + + val probeMask = _capacity + var probeOffset = h1(hash) and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + var m = g.match(hash2) + while (m.hasNext()) { + val index = (probeOffset + m.get()) and probeMask + if (keys[index] == key) { + return index + } + m = m.next() + } + + if (g.maskEmpty() != 0L) { + break + } + + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + + return -1 + } + + /** + * Wraps this [ScatterMap] with a [Map] interface. The [Map] is backed + * by the [ScatterMap], so changes to the [ScatterMap] are reflected + * in the [Map]. If the [ScatterMap] is modified while an iteration over + * the [Map] is in progress, the results of the iteration are undefined. + * + * **Note**: while this method is useful to use this [ScatterMap] with APIs + * accepting [Map] interfaces, it is less efficient to do so than to use + * [ScatterMap]'s APIs directly. While the [Map] implementation returned by + * this method tries to be as efficient as possible, the semantics of [Map] + * may require the allocation of temporary objects for access and iteration. + */ + public fun asMap(): Map = MapWrapper() + + // TODO: While not mandatory, it would be pertinent to throw a + // ConcurrentModificationException when the underlying ScatterMap + // is modified while iterating over keys/values/entries. To do + // this we should probably have some kind of generation ID in + // ScatterMap that would be incremented on any add/remove/clear + // or rehash. + // + // TODO: the proliferation of inner classes causes unnecessary code to be + // created. For instance, `entries.size` below requires a total of + // 3 `getfield` to resolve the chain of `this` before getting the + // `_size` field. This is likely bad in the various loops like + // `containsAll()` etc. We should probably instead create named + // classes that take a `ScatterMap` as a parameter to refer to it + // directly. + internal open inner class MapWrapper : Map { + override val entries: Set> + get() = object : Set> { + override val size: Int get() = this@ScatterMap._size + + override fun isEmpty(): Boolean = this@ScatterMap.isEmpty() + + override fun iterator(): Iterator> { + return iterator { + this@ScatterMap.forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + yield( + MapEntry( + this@ScatterMap.keys[index] as K, + this@ScatterMap.values[index] as V + ) + ) + } + } + } + + override fun containsAll(elements: Collection>): Boolean = + elements.all { this@ScatterMap[it.key] == it.value } + + override fun contains(element: Map.Entry): Boolean = + this@ScatterMap[element.key] == element.value + } + + override val keys: Set + get() = object : Set { + override val size: Int get() = this@ScatterMap._size + + override fun isEmpty(): Boolean = this@ScatterMap.isEmpty() + + override fun iterator(): Iterator = iterator { + this@ScatterMap.forEachKey { key -> + yield(key) + } + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { this@ScatterMap.containsKey(it) } + + override fun contains(element: K): Boolean = this@ScatterMap.containsKey(element) + } + + override val values: Collection + get() = object : Collection { + override val size: Int get() = this@ScatterMap._size + + override fun isEmpty(): Boolean = this@ScatterMap.isEmpty() + + override fun iterator(): Iterator = iterator { + this@ScatterMap.forEachValue { value -> + yield(value) + } + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { this@ScatterMap.containsValue(it) } + + override fun contains(element: V): Boolean = this@ScatterMap.containsValue(element) + } + + override val size: Int get() = this@ScatterMap._size + + override fun isEmpty(): Boolean = this@ScatterMap.isEmpty() + + // TODO: @Suppress required because of a lint check issue (b/294130025) + override fun get(@Suppress("MissingNullability") key: K): V? = this@ScatterMap[key] + + override fun containsValue(value: V): Boolean = this@ScatterMap.containsValue(value) + + override fun containsKey(key: K): Boolean = this@ScatterMap.containsKey(key) + } +} + +/** + * [MutableScatterMap] is a container with a [Map]-like interface based on a flat + * hash table implementation (the key/value mappings are not stored by nodes + * but directly into arrays). The underlying implementation is designed to avoid + * all allocations on insertion, removal, retrieval, and iteration. Allocations + * may still happen on insertion when the underlying storage needs to grow to + * accommodate newly added entries to the table. In addition, this implementation + * minimizes memory usage by avoiding the use of separate objects to hold + * key/value pairs. + * + * This implementation makes no guarantee as to the order of the keys and + * values stored, nor does it make guarantees that the order remains constant + * over time. + * + * This implementation is not thread-safe: if multiple threads access this + * container concurrently, and one or more threads modify the structure of + * the map (insertion or removal for instance), the calling code must provide + * the appropriate synchronization. Multiple threads are safe to read from this + * map concurrently if no write is happening. + * + * **Note**: when a [Map] is absolutely necessary, you can use the method + * [asMap] to create a thin wrapper around a [MutableScatterMap]. Please refer + * to [asMap] for more details and caveats. + * + * **Note**: when a [MutableMap] is absolutely necessary, you can use the + * method [asMutableMap] to create a thin wrapper around a [MutableScatterMap]. + * Please refer to [asMutableMap] for more details and caveats. + * + * **MutableScatterMap and SimpleArrayMap**: like [SimpleArrayMap], + * [MutableScatterMap] is designed to avoid the allocation of + * extra objects when inserting new entries in the map. However, the + * implementation of [MutableScatterMap] offers better performance + * characteristics compared to [SimpleArrayMap] and is thus generally + * preferable. If memory usage is a concern, [SimpleArrayMap] automatically + * shrinks its storage to avoid using more memory than necessary. You can + * also control memory usage with [MutableScatterMap] by manually calling + * [MutableScatterMap.trim]. + * + * @constructor Creates a new [MutableScatterMap] + * @param initialCapacity The initial desired capacity for this container. + * the container will honor this value by guaranteeing its internal structures + * can hold that many entries without requiring any allocations. The initial + * capacity can be set to 0. + * + * @see Map + */ +public class MutableScatterMap( + initialCapacity: Int = DefaultScatterCapacity +) : ScatterMap() { + // Number of entries we can add before we need to grow + private var growthLimit = 0 + + init { + requirePrecondition(initialCapacity >= 0) { "Capacity must be a positive value." } + initializeStorage(unloadedCapacity(initialCapacity)) + } + + private fun initializeStorage(initialCapacity: Int) { + val newCapacity = if (initialCapacity > 0) { + // Since we use longs for storage, our capacity is never < 7, enforce + // it here. We do have a special case for 0 to create small empty maps + max(7, normalizeCapacity(initialCapacity)) + } else { + 0 + } + _capacity = newCapacity + initializeMetadata(newCapacity) + keys = arrayOfNulls(newCapacity) + values = arrayOfNulls(newCapacity) + } + + private fun initializeMetadata(capacity: Int) { + metadata = if (capacity == 0) { + EmptyGroup + } else { + // Round up to the next multiple of 8 and find how many longs we need + val size = (((capacity + 1 + ClonedMetadataCount) + 7) and 0x7.inv()) shr 3 + LongArray(size).apply { + fill(AllEmpty) + } + } + writeRawMetadata(metadata, capacity, Sentinel) + initializeGrowth() + } + + private fun initializeGrowth() { + growthLimit = loadedCapacity(capacity) - _size + } + + /** + * Returns the value to which the specified [key] is mapped, + * if the value is present in the map and not `null`. Otherwise, + * calls `defaultValue()` and puts the result in the map associated + * with [key]. + */ + public inline fun getOrPut(key: K, defaultValue: () -> V): V { + return get(key) ?: defaultValue().also { set(key, it) } + } + + /** + * Retrieves a value for [key] and computes a new value based on the existing value (or + * `null` if the key is not in the map). The computed value is then stored in the map for the + * given [key]. + * + * @return value computed by `computeBlock`. + */ + public inline fun compute(key: K, computeBlock: (key: K, value: V?) -> V): V { + val index = findInsertIndex(key) + val inserting = index < 0 + + @Suppress("UNCHECKED_CAST") + val computedValue = computeBlock( + key, + if (inserting) null else values[index] as V + ) + + // Skip Array.set() if key is already there + if (inserting) { + val insertionIndex = index.inv() + keys[insertionIndex] = key + values[insertionIndex] = computedValue + } else { + values[index] = computedValue + } + return computedValue + } + + /** + * Creates a new mapping from [key] to [value] in this map. If [key] is + * already present in the map, the association is modified and the previously + * associated value is replaced with [value]. If [key] is not present, a new + * entry is added to the map, which may require to grow the underlying storage + * and cause allocations. + */ + public operator fun set(key: K, value: V) { + val index = findInsertIndex(key).let { index -> + if (index < 0) index.inv() else index + } + keys[index] = key + values[index] = value + } + + /** + * Creates a new mapping from [key] to [value] in this map. If [key] is + * already present in the map, the association is modified and the previously + * associated value is replaced with [value]. If [key] is not present, a new + * entry is added to the map, which may require to grow the underlying storage + * and cause allocations. Return the previous value associated with the [key], + * or `null` if the key was not present in the map. + */ + public fun put(key: K, value: V): V? { + val index = findInsertIndex(key).let { index -> + if (index < 0) index.inv() else index + } + val oldValue = values[index] + keys[index] = key + values[index] = value + + @Suppress("UNCHECKED_CAST") + return oldValue as V? + } + + /** + * Puts all the [pairs] into this map, using the first component of the pair + * as the key, and the second component as the value. + */ + public fun putAll(@Suppress("ArrayReturn") pairs: Array>) { + for ((key, value) in pairs) { + this[key] = value + } + } + + /** + * Puts all the [pairs] into this map, using the first component of the pair + * as the key, and the second component as the value. + */ + public fun putAll(pairs: Iterable>) { + for ((key, value) in pairs) { + this[key] = value + } + } + + /** + * Puts all the [pairs] into this map, using the first component of the pair + * as the key, and the second component as the value. + */ + public fun putAll(pairs: Sequence>) { + for ((key, value) in pairs) { + this[key] = value + } + } + + /** + * Puts all the key/value mappings in the [from] map into this map. + */ + public fun putAll(from: Map) { + from.forEach { (key, value) -> + this[key] = value + } + } + + /** + * Puts all the key/value mappings in the [from] map into this map. + */ + public fun putAll(from: ScatterMap) { + from.forEach { key, value -> + this[key] = value + } + } + + /** + * Puts the key/value mapping from the [pair] in this map, using the first + * element as the key, and the second element as the value. + */ + public inline operator fun plusAssign(pair: Pair) { + this[pair.first] = pair.second + } + + /** + * Puts all the [pairs] into this map, using the first component of the pair + * as the key, and the second component as the value. + */ + public inline operator fun plusAssign( + @Suppress("ArrayReturn") pairs: Array> + ): Unit = putAll(pairs) + + /** + * Puts all the [pairs] into this map, using the first component of the pair + * as the key, and the second component as the value. + */ + public inline operator fun plusAssign(pairs: Iterable>): Unit = putAll(pairs) + + /** + * Puts all the [pairs] into this map, using the first component of the pair + * as the key, and the second component as the value. + */ + public inline operator fun plusAssign(pairs: Sequence>): Unit = putAll(pairs) + + /** + * Puts all the key/value mappings in the [from] map into this map. + */ + public inline operator fun plusAssign(from: Map): Unit = putAll(from) + + /** + * Puts all the key/value mappings in the [from] map into this map. + */ + public inline operator fun plusAssign(from: ScatterMap): Unit = putAll(from) + + /** + * Removes the specified [key] and its associated value from the map. If the + * [key] was present in the map, this function returns the value that was + * present before removal. + */ + public fun remove(key: K): V? { + val index = findKeyIndex(key) + if (index >= 0) { + return removeValueAt(index) + } + return null + } + + /** + * Removes the specified [key] and its associated value from the map if the + * associated value equals [value]. Returns whether the removal happened. + */ + public fun remove(key: K, value: V): Boolean { + val index = findKeyIndex(key) + if (index >= 0) { + if (values[index] == value) { + removeValueAt(index) + return true + } + } + return false + } + + /** + * Removes any mapping for which the specified [predicate] returns true. + */ + public inline fun removeIf(predicate: (K, V) -> Boolean) { + forEachIndexed { index -> + @Suppress("UNCHECKED_CAST") + if (predicate(keys[index] as K, values[index] as V)) { + removeValueAt(index) + } + } + } + + /** + * Removes the specified [key] and its associated value from the map. + */ + public inline operator fun minusAssign(key: K) { + remove(key) + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(@Suppress("ArrayReturn") keys: Array) { + for (key in keys) { + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(keys: Iterable) { + for (key in keys) { + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(keys: Sequence) { + for (key in keys) { + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ + public inline operator fun minusAssign(keys: ScatterSet) { + keys.forEach { key -> + remove(key) + } + } + + /** + * Removes the specified [keys] and their associated value from the map. + */ +// public inline operator fun minusAssign(keys: ObjectList) { +// keys.forEach { key -> +// remove(key) +// } +// } + + @PublishedApi + internal fun removeValueAt(index: Int): V? { + _size -= 1 + + // TODO: We could just mark the entry as empty if there's a group + // window around this entry that was already empty + writeMetadata(metadata, _capacity, index, Deleted) + keys[index] = null + val oldValue = values[index] + values[index] = null + + @Suppress("UNCHECKED_CAST") + return oldValue as V? + } + + /** + * Removes all mappings from this map. + */ + public fun clear() { + _size = 0 + if (metadata !== EmptyGroup) { + metadata.fill(AllEmpty) + writeRawMetadata(metadata, _capacity, Sentinel) + } + values.fill(null, 0, _capacity) + keys.fill(null, 0, _capacity) + initializeGrowth() + } + + /** + * Scans the hash table to find the index at which we can store a value + * for the give [key]. If the key already exists in the table, its index + * will be returned, otherwise the `index.inv()` of an empty slot will be returned. + * Calling this function may cause the internal storage to be reallocated + * if the table is full. + */ + @PublishedApi + internal fun findInsertIndex(key: K): Int { + val hash = hash(key) + val hash1 = h1(hash) + val hash2 = h2(hash) + + val probeMask = _capacity + var probeOffset = hash1 and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + var m = g.match(hash2) + while (m.hasNext()) { + val index = (probeOffset + m.get()) and probeMask + if (keys[index] == key) { + return index + } + m = m.next() + } + + if (g.maskEmpty() != 0L) { + break + } + + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + + var index = findFirstAvailableSlot(hash1) + if (growthLimit == 0 && !isDeleted(metadata, index)) { + adjustStorage() + index = findFirstAvailableSlot(hash1) + } + + _size += 1 + growthLimit -= if (isEmpty(metadata, index)) 1 else 0 + writeMetadata(metadata, _capacity, index, hash2.toLong()) + + return index.inv() + } + + /** + * Finds the first empty or deleted slot in the table in which we can + * store a value without resizing the internal storage. + */ + private fun findFirstAvailableSlot(hash1: Int): Int { + val probeMask = _capacity + var probeOffset = hash1 and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + val m = g.maskEmptyOrDeleted() + if (m != 0L) { + return (probeOffset + m.lowestBitSet()) and probeMask + } + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + } + + /** + * Trims this [MutableScatterMap]'s storage so it is sized appropriately + * to hold the current mappings. + * + * Returns the number of empty entries removed from this map's storage. + * Returns be 0 if no trimming is necessary or possible. + */ + public fun trim(): Int { + val previousCapacity = _capacity + val newCapacity = normalizeCapacity(unloadedCapacity(_size)) + if (newCapacity < previousCapacity) { + resizeStorage(newCapacity) + return previousCapacity - _capacity + } + return 0 + } + + /** + * Grow internal storage if necessary. This function can instead opt to + * remove deleted entries from the table to avoid an expensive reallocation + * of the underlying storage. This "rehash in place" occurs when the + * current size is <= 25/32 of the table capacity. The choice of 25/32 is + * detailed in the implementation of abseil's `raw_hash_set`. + */ + private fun adjustStorage() { + if (_capacity > GroupWidth && _size.toULong() * 32UL <= _capacity.toULong() * 25UL) { + dropDeletes() + } else { + resizeStorage(nextCapacity(_capacity)) + } + } + + private fun dropDeletes() { + val metadata = metadata + val capacity = _capacity + val keys = keys + val values = values + + // Converts Sentinel and Deleted to Empty, and Full to Deleted + convertMetadataForCleanup(metadata, capacity) + + var swapIndex = -1 + var index = 0 + + // Drop deleted items and re-hashes surviving entries + while (index != capacity) { + var m = readRawMetadata(metadata, index) + // Formerly Deleted entry, we can use it as a swap spot + if (m == Empty) { + swapIndex = index + index++ + continue + } + + // Formerly Full entries are now marked Deleted. If we see an + // entry that's not marked Deleted, we can ignore it completely + if (m != Deleted) { + index++ + continue + } + + val hash = hash(keys[index]) + val hash1 = h1(hash) + val targetIndex = findFirstAvailableSlot(hash1) + + // Test if the current index (i) and the new index (targetIndex) fall + // within the same group based on the hash. If the group doesn't change, + // we don't move the entry + val probeOffset = hash1 and capacity + val newProbeIndex = ((targetIndex - probeOffset) and capacity) / GroupWidth + val oldProbeIndex = ((index - probeOffset) and capacity) / GroupWidth + + if (newProbeIndex == oldProbeIndex) { + val hash2 = h2(hash) + writeRawMetadata(metadata, index, hash2.toLong()) + + // Copies the metadata into the clone area + metadata[metadata.lastIndex] = metadata[0] + + index++ + continue + } + + m = readRawMetadata(metadata, targetIndex) + if (m == Empty) { + // The target is empty so we can transfer directly + val hash2 = h2(hash) + writeRawMetadata(metadata, targetIndex, hash2.toLong()) + writeRawMetadata(metadata, index, Empty) + + keys[targetIndex] = keys[index] + keys[index] = null + + values[targetIndex] = values[index] + values[index] = null + + swapIndex = index + } else /* m == Deleted */ { + // The target isn't empty so we use an empty slot denoted by + // swapIndex to perform the swap + val hash2 = h2(hash) + writeRawMetadata(metadata, targetIndex, hash2.toLong()) + + if (swapIndex == -1) { + swapIndex = findEmptySlot(metadata, index + 1, capacity) + } + + keys[swapIndex] = keys[targetIndex] + keys[targetIndex] = keys[index] + keys[index] = keys[swapIndex] + + values[swapIndex] = values[targetIndex] + values[targetIndex] = values[index] + values[index] = values[swapIndex] + + // Since we exchanged two slots we must repeat the process with + // element we just moved in the current location + index-- + } + + // Copies the metadata into the clone area + metadata[metadata.lastIndex] = metadata[0] + + index++ + } + + initializeGrowth() + } + + private fun resizeStorage(newCapacity: Int) { + val previousMetadata = metadata + val previousKeys = keys + val previousValues = values + val previousCapacity = _capacity + + initializeStorage(newCapacity) + + val newMetadata = metadata + val newKeys = keys + val newValues = values + val capacity = _capacity + + for (i in 0 until previousCapacity) { + if (isFull(previousMetadata, i)) { + val previousKey = previousKeys[i] + val hash = hash(previousKey) + val index = findFirstAvailableSlot(h1(hash)) + + writeMetadata(newMetadata, capacity, index, h2(hash).toLong()) + newKeys[index] = previousKey + newValues[index] = previousValues[i] + } + } + } + + /** + * Writes the "H2" part of an entry into the metadata array at the specified + * [index]. The index must be a valid index. This function ensures the + * metadata is also written in the clone area at the end. + */ + private inline fun writeMetadata(index: Int, value: Long) { + val m = metadata + writeRawMetadata(m, index, value) + + // Mirroring + val c = _capacity + val cloneIndex = ((index - ClonedMetadataCount) and c) + + (ClonedMetadataCount and c) + writeRawMetadata(m, cloneIndex, value) + } + + /** + * Wraps this [ScatterMap] with a [MutableMap] interface. The [MutableMap] + * is backed by the [ScatterMap], so changes to the [ScatterMap] are + * reflected in the [MutableMap] and vice-versa. If the [ScatterMap] is + * modified while an iteration over the [MutableMap] is in progress (and vice- + * versa), the results of the iteration are undefined. + * + * **Note**: while this method is useful to use this [MutableScatterMap] + * with APIs accepting [MutableMap] interfaces, it is less efficient to do + * so than to use [MutableScatterMap]'s APIs directly. While the [MutableMap] + * implementation returned by this method tries to be as efficient as possible, + * the semantics of [MutableMap] may require the allocation of temporary + * objects for access and iteration. + */ + public fun asMutableMap(): MutableMap = MutableMapWrapper() + + // TODO: See TODO on `MapWrapper` + private inner class MutableMapWrapper : MapWrapper(), MutableMap { + override val entries: MutableSet> + get() = object : MutableSet> { + override val size: Int get() = this@MutableScatterMap._size + + override fun isEmpty(): Boolean = this@MutableScatterMap.isEmpty() + + override fun iterator(): MutableIterator> = + object : MutableIterator> { + + var iterator: Iterator> + var current = -1 + + init { + iterator = iterator { + this@MutableScatterMap.forEachIndexed { index -> + current = index + yield( + MutableMapEntry( + this@MutableScatterMap.keys, + this@MutableScatterMap.values, + current + ) + ) + } + } + } + + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): MutableMap.MutableEntry = iterator.next() + + override fun remove() { + if (current != -1) { + this@MutableScatterMap.removeValueAt(current) + current = -1 + } + } + } + + override fun clear() { + this@MutableScatterMap.clear() + } + + override fun containsAll( + elements: Collection> + ): Boolean { + return elements.all { this@MutableScatterMap[it.key] == it.value } + } + + override fun contains(element: MutableMap.MutableEntry): Boolean = + this@MutableScatterMap[element.key] == element.value + + override fun addAll(elements: Collection>): Boolean { + throw UnsupportedOperationException() + } + + override fun add(element: MutableMap.MutableEntry): Boolean { + throw UnsupportedOperationException() + } + + override fun retainAll( + elements: Collection> + ): Boolean { + var changed = false + this@MutableScatterMap.forEachIndexed { index -> + var found = false + for (entry in elements) { + if (entry.key == this@MutableScatterMap.keys[index] && + entry.value == this@MutableScatterMap.values[index] + ) { + found = true + break + } + } + if (!found) { + removeValueAt(index) + changed = true + } + } + return changed + } + + override fun removeAll( + elements: Collection> + ): Boolean { + var changed = false + this@MutableScatterMap.forEachIndexed { index -> + for (entry in elements) { + if (entry.key == this@MutableScatterMap.keys[index] && + entry.value == this@MutableScatterMap.values[index] + ) { + removeValueAt(index) + changed = true + break + } + } + } + return changed + } + + override fun remove(element: MutableMap.MutableEntry): Boolean { + val index = findKeyIndex(element.key) + if (index >= 0 && this@MutableScatterMap.values[index] == element.value) { + removeValueAt(index) + return true + } + return false + } + } + + override val keys: MutableSet + get() = object : MutableSet { + override val size: Int get() = this@MutableScatterMap._size + + override fun isEmpty(): Boolean = this@MutableScatterMap.isEmpty() + + override fun iterator(): MutableIterator = object : MutableIterator { + private val iterator = iterator { + this@MutableScatterMap.forEachIndexed { index -> + yield(index) + } + } + private var current: Int = -1 + + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): K { + current = iterator.next() + @Suppress("UNCHECKED_CAST") + return this@MutableScatterMap.keys[current] as K + } + + override fun remove() { + if (current >= 0) { + this@MutableScatterMap.removeValueAt(current) + current = -1 + } + } + } + + override fun clear() { + this@MutableScatterMap.clear() + } + + override fun addAll(elements: Collection): Boolean { + throw UnsupportedOperationException() + } + + override fun add(element: K): Boolean { + throw UnsupportedOperationException() + } + + override fun retainAll(elements: Collection): Boolean { + var changed = false + this@MutableScatterMap.forEachIndexed { index -> + if (this@MutableScatterMap.keys[index] !in elements) { + removeValueAt(index) + changed = true + } + } + return changed + } + + override fun removeAll(elements: Collection): Boolean { + var changed = false + this@MutableScatterMap.forEachIndexed { index -> + if (this@MutableScatterMap.keys[index] in elements) { + removeValueAt(index) + changed = true + } + } + return changed + } + + override fun remove(element: K): Boolean { + val index = findKeyIndex(element) + if (index >= 0) { + removeValueAt(index) + return true + } + return false + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { this@MutableScatterMap.containsKey(it) } + + override fun contains(element: K): Boolean = + this@MutableScatterMap.containsKey(element) + } + + override val values: MutableCollection + get() = object : MutableCollection { + override val size: Int get() = this@MutableScatterMap._size + + override fun isEmpty(): Boolean = this@MutableScatterMap.isEmpty() + + override fun iterator(): MutableIterator = object : MutableIterator { + private val iterator = iterator { + this@MutableScatterMap.forEachIndexed { index -> + yield(index) + } + } + private var current: Int = -1 + + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): V { + current = iterator.next() + @Suppress("UNCHECKED_CAST") + return this@MutableScatterMap.values[current] as V + } + + override fun remove() { + if (current >= 0) { + this@MutableScatterMap.removeValueAt(current) + current = -1 + } + } + } + + override fun clear() { + this@MutableScatterMap.clear() + } + + override fun addAll(elements: Collection): Boolean { + throw UnsupportedOperationException() + } + + override fun add(element: V): Boolean { + throw UnsupportedOperationException() + } + + override fun retainAll(elements: Collection): Boolean { + var changed = false + this@MutableScatterMap.forEachIndexed { index -> + if (this@MutableScatterMap.values[index] !in elements) { + removeValueAt(index) + changed = true + } + } + return changed + } + + override fun removeAll(elements: Collection): Boolean { + var changed = false + this@MutableScatterMap.forEachIndexed { index -> + if (this@MutableScatterMap.values[index] in elements) { + removeValueAt(index) + changed = true + } + } + return changed + } + + override fun remove(element: V): Boolean { + this@MutableScatterMap.forEachIndexed { index -> + if (this@MutableScatterMap.values[index] == element) { + removeValueAt(index) + return true + } + } + return false + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { this@MutableScatterMap.containsValue(it) } + + override fun contains(element: V): Boolean = + this@MutableScatterMap.containsValue(element) + } + + override fun clear() { + this@MutableScatterMap.clear() + } + + override fun remove(key: K): V? = this@MutableScatterMap.remove(key) + + override fun putAll(from: Map) { + from.forEach { (key, value) -> + this[key] = value + } + } + + override fun put(key: K, value: V): V? = this@MutableScatterMap.put(key, value) + } +} + +internal fun convertMetadataForCleanup(metadata: LongArray, capacity: Int) { + val end = (capacity + 7) shr 3 + for (i in 0 until end) { + // Converts Sentinel and Deleted to Empty, and Full to Deleted + val maskedGroup = metadata[i] and BitmaskMsb + metadata[i] = (maskedGroup.inv() + (maskedGroup ushr 7)) and BitmaskLsb.inv() + } + + val lastIndex = metadata.lastIndex + // Restores the sentinel that we overwrote above + metadata[lastIndex - 1] = + (Sentinel shl 56) or (metadata[lastIndex - 1] and 0x00ffffff_ffffffffL) + // Copies the metadata into the clone area + metadata[lastIndex] = metadata[0] +} + +internal fun findEmptySlot(metadata: LongArray, start: Int, end: Int): Int { + for (i in start until end) { + if (readRawMetadata(metadata, i) == Empty) { + return i + } + } + return -1 +} + +/** + * Returns the hash code of [k]. The hash spreads low bits to to minimize collisions in high + * 25-bits that are used for probing. + */ +internal inline fun hash(k: Any?): Int { + // scramble bits to account for collisions between similar hash values. + val hash = k.hashCode() * MurmurHashC1 + // spread low bits into high bits that are used for probing + return hash xor (hash shl 16) +} + +// C1 constant from MurmurHash implementation: https://en.wikipedia.org/wiki/MurmurHash#Algorithm +internal const val MurmurHashC1: Int = 0xcc9e2d51.toInt() + +// Returns the "H1" part of the specified hash code. In our implementation, +// it is simply the top-most 25 bits +internal inline fun h1(hash: Int) = hash ushr 7 + +// Returns the "H2" part of the specified hash code. In our implementation, +// this corresponds to the lower 7 bits +internal inline fun h2(hash: Int) = hash and 0x7f + +// Assumes [capacity] was normalized with [normalizedCapacity]. +// Returns the next 2^m - 1 +internal fun nextCapacity(capacity: Int) = if (capacity == 0) { + DefaultScatterCapacity +} else { + capacity * 2 + 1 +} + +// n -> nearest 2^m - 1 +internal fun normalizeCapacity(n: Int) = + if (n > 0) (0xffffffff.toInt() ushr n.countLeadingZeroBits()) else 0 + +// Computes the growth based on a load factor of 7/8 for the general case. +// When capacity is < GroupWidth - 1, we use a load factor of 1 instead +internal fun loadedCapacity(capacity: Int): Int { + // Special cases where x - x / 8 fails + if (GroupWidth <= 8 && capacity == 7) { + return 6 + } + // If capacity is < GroupWidth - 1 we end up here and this formula + // will return `capacity` in this case, which is what we want + return capacity - capacity / 8 +} + +// Inverse of loadedCapacity() +internal fun unloadedCapacity(capacity: Int): Int { + // Special cases where x + (x - 1) / 7 + if (GroupWidth <= 8 && capacity == 7) { + return 8 + } + return capacity + (capacity - 1) / 7 +} + +/** + * Reads a single byte from the long array at the specified [offset] in *bytes*. + */ +@PublishedApi +internal inline fun readRawMetadata(data: LongArray, offset: Int): Long { + // Take the Long at index `offset / 8` and shift by `offset % 8` + // A longer explanation can be found in [group()]. + return (data[offset shr 3] shr ((offset and 0x7) shl 3)) and 0xff +} + +/** + * Writes a single byte into the long array at the specified [offset] in *bytes* and copies it, if + * necessary, into the cloned bytes section at the end of the array. + * + * NOTE: [value] must be a single byte, accepted here as a Long to avoid unnecessary conversions. + */ +internal inline fun writeMetadata(data: LongArray, capacity: Int, offset: Int, value: Long) { + writeRawMetadata(data, offset, value) + + // Mirroring + // We could/should write a single byte when cloning the metadata by calling + // writeRawMetadata(), but since our implementation uses Longs for storage, + // we always write a full Long. We can skip a bit of unnecessary work by just + // copying the whole group the index falls into. + // When index is in 0..7, we copy the group over the control bytes at the end of + // the array, otherwise the group is copied onto itself (cloneIndex shr 3 == index shr 3) + // TODO: We could further reduce the work we do by always copying index 0 to + // lastIndex, but is it interesting in terms of data caches? + val cloneIndex = + ((offset - ClonedMetadataCount) and capacity) + (ClonedMetadataCount and capacity) + data[cloneIndex shr 3] = data[offset shr 3] +} + +/** + * Writes a single byte into the long array at the specified [offset] in *bytes*. + * + * NOTE: [value] must be a single byte, accepted here as a Long to avoid unnecessary conversions. + */ +internal inline fun writeRawMetadata(data: LongArray, offset: Int, value: Long) { + // See [group()] for details. First find the index i in the LongArray, + // then find the number of bits we need to shift by + val i = offset shr 3 + val b = (offset and 0x7) shl 3 + // Mask the source data with 0xFF in the right place, then and [value] + // moved to the right spot + data[i] = (data[i] and (0xffL shl b).inv()) or (value shl b) +} + +internal inline fun isEmpty(metadata: LongArray, index: Int) = + readRawMetadata(metadata, index) == Empty +internal inline fun isDeleted(metadata: LongArray, index: Int) = + readRawMetadata(metadata, index) == Deleted + +internal inline fun isFull(metadata: LongArray, index: Int): Boolean = + readRawMetadata(metadata, index) < 0x80L + +@PublishedApi +internal inline fun isFull(value: Long): Boolean = value < 0x80L + +// Bitmasks in our context are abstract bitmasks. They represent a bitmask +// for a Group. i.e. bit 1 is the second least significant byte in the group. +// These bits are also called "abstract bits". For example, given the +// following group of metadata and a group width of 8: +// +// 0x7700550033001100 +// | | | | |___ bit 0 = 0x00 +// | | | |_____ bit 1 = 0x11 +// | | |_________ bit 3 = 0x33 +// | |_____________ bit 5 = 0x55 +// |_________________ bit 7 = 0x77 +// +// This is useful when performing group operations to figure out, for +// example, which metadata is set or not. +// +// A static bitmask is a read-only bitmask that allows performing simple +// queries such as [lowestBitSet]. +internal typealias StaticBitmask = Long +// A dynamic bitmask is a bitmask that can be iterated on to retrieve, +// for instance, the index of all the "abstract bits" set on the group. +// This assumes the abstract bits are set to either 0x00 (for unset) and +// 0x80 (for set). +internal typealias Bitmask = Long + +@PublishedApi +internal inline fun StaticBitmask.lowestBitSet(): Int = countTrailingZeroBits() shr 3 + +/** + * Returns the index of the next set bit in this mask. If invoked before checking + * [hasNext], this function returns an invalid index (8). + */ +internal inline fun Bitmask.get() = lowestBitSet() + +/** + * Moves to the next set bit and returns the modified bitmask, call [get] to + * get the actual index. If this function is called before checking [hasNext], + * the result is invalid. + */ +internal inline fun Bitmask.next() = this and (this - 1L) + +/** + * Returns true if this [Bitmask] contains more set bits. + */ +internal inline fun Bitmask.hasNext() = this != 0L + +// Least significant bits in the bitmask, one for each metadata in the group +@PublishedApi internal const val BitmaskLsb: Long = 0x01010101_01010101L + +// Most significant bits in the bitmask, one for each metadata in the group +// +// NOTE: Ideally we'd use a ULong here, defined as 0x8080808080808080UL but +// using ULong/UByte makes us take a ~10% performance hit on get/set compared to +// a Long. And since Kotlin hates signed constants, we have to use +// -0x7f7f7f7f7f7f7f80L instead of the more sensible 0x8080808080808080L (and +// 0x8080808080808080UL.toLong() isn't considered a constant) +@PublishedApi internal const val BitmaskMsb: Long = -0x7f7f7f7f_7f7f7f80L // srsly Kotlin @#! + +/** + * Creates a [Group] from a metadata array, starting at the specified offset. + * [offset] must be a valid index in the source array. + */ +internal inline fun group(metadata: LongArray, offset: Int): Group { + // A Group is a Long read at an arbitrary byte-grained offset inside the + // Long array. To read the Group, we need to read 2 Longs: one for the + // most significant bits (MSBs) and one for the least significant bits + // (LSBs). + // Let's take an example, with a LongArray of 2 and an offset set to 1 + // byte. We need to read 7 bytes worth of LSBs in Long 0 and 1 byte worth + // of MSBs in Long 1 (remember we index the bytes from LSB to MSB so in + // the example below byte 0 is 0x11 and byte 11 is 0xAA): + // + // ___________________ LongArray ____________________ + // | | + // [88 77 66 55 44 33 22 11], [FF EE DD CC BB AA 00 99] + // |_________Long0_______ _| |_________Long1_______ _| + // + // To retrieve the Group we first find the index of Long0 by taking the + // offset divided by 8. Then offset modulo 8 gives us how many bits we + // need to shift by. With offset = 1: + // + // index = offset / 8 == 0 + // remainder = offset % 8 == 1 + // bitsToShift = remainder * 8 + // + // LSBs = LongArray[index] >>> bitsToShift + // MSBs = LongArray[index + 1] << (64 - bitsToShift) + // + // We now have: + // + // LSBs == 0x0088776655443322 + // MSBs == 0x9900000000000000 + // + // However we can't just combine MSBs and LSBs with an OR when the offset + // is a multiple of 8, because we would be attempting to shift left by 64 + // which is a no-op. This means we need to mask the MSBs with 0x0 when + // offset is 0, and with 0xFF…FF when offset is != 0. We do this by taking + // the negative value of `bitsToShift`, which will set the MSB when the value + // is not 0, and doing a signed shift to the right to duplicate it: + // + // Group = LSBs | (MSBs & (-b >> 63) + // + // Note: since b is only ever 0, 8, 16, 24, 32, 48, 56, or 64, we don't + // need to shift by 63, we could shift by only 5 + val i = offset shr 3 + val b = (offset and 0x7) shl 3 + return (metadata[i] ushr b) or (metadata[i + 1] shl (64 - b) and (-(b.toLong()) shr 63)) +} + +/** + * Returns a [Bitmask] in which every abstract bit set means the corresponding + * metadata in that slot is equal to [m]. + */ +@PublishedApi +internal inline fun Group.match(m: Int): Bitmask { + // BitmaskLsb * m replicates the byte `m` on every byte of the Long + // and XOR-ing with `this` will give us a Long in which every non-zero + // byte indicates a match + val x = this xor (BitmaskLsb * m) + // Turn every non-zero byte into 0x80 + return (x - BitmaskLsb) and x.inv() and BitmaskMsb +} + +/** + * Returns a [Bitmask] in which every abstract bit set indicates an empty slot. + */ +internal inline fun Group.maskEmpty(): Bitmask { + return (this and (this.inv() shl 6)) and BitmaskMsb +} + +/** + * Returns a [Bitmask] in which every abstract bit set indicates an empty or deleted slot. + */ +@PublishedApi +internal inline fun Group.maskEmptyOrDeleted(): Bitmask { + return (this and (this.inv() shl 7)) and BitmaskMsb +} + +private class MapEntry(override val key: K, override val value: V) : Map.Entry + +private class MutableMapEntry( + val keys: Array, + val values: Array, + val index: Int +) : MutableMap.MutableEntry { + + @Suppress("UNCHECKED_CAST") + override fun setValue(newValue: V): V { + val oldValue = values[index] + values[index] = newValue + return oldValue as V + } + + @Suppress("UNCHECKED_CAST") + override val key: K get() = keys[index] as K + + @Suppress("UNCHECKED_CAST") + override val value: V get() = values[index] as V +} diff --git a/core/src/commonMain/kotlin/androidx/collection/ScatterSet.kt b/core/src/commonMain/kotlin/androidx/collection/ScatterSet.kt new file mode 100644 index 0000000..6a2146b --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/collection/ScatterSet.kt @@ -0,0 +1,1102 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "RedundantVisibilityModifier", + "KotlinRedundantDiagnosticSuppress", + "KotlinConstantConditions", + "PropertyName", + "ConstPropertyName", + "PrivatePropertyName", + "NOTHING_TO_INLINE" +) +@file:OptIn(ExperimentalContracts::class) + +package androidx.collection + +import androidx.annotation.IntRange +import androidx.collection.internal.EMPTY_OBJECTS +import androidx.collection.internal.requirePrecondition +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import kotlin.jvm.JvmField +import kotlin.jvm.JvmOverloads + + +typealias ObjectList = List + +// This is a copy of ScatterMap, but without values + +// Default empty set to avoid allocations +private val EmptyScatterSet = MutableScatterSet(0) + +/** Returns an empty, read-only [ScatterSet]. */ +@Suppress("UNCHECKED_CAST") +public fun emptyScatterSet(): ScatterSet = EmptyScatterSet as ScatterSet + +/** Returns an empty, read-only [ScatterSet]. */ +@Suppress("UNCHECKED_CAST") +public fun scatterSetOf(): ScatterSet = EmptyScatterSet as ScatterSet + +/** Returns a new read-only [ScatterSet] with only [element1] in it. */ +@Suppress("UNCHECKED_CAST") +public fun scatterSetOf(element1: E): ScatterSet = mutableScatterSetOf(element1) + +/** Returns a new read-only [ScatterSet] with only [element1] and [element2] in it. */ +@Suppress("UNCHECKED_CAST") +public fun scatterSetOf(element1: E, element2: E): ScatterSet = + mutableScatterSetOf(element1, element2) + +/** Returns a new read-only [ScatterSet] with only [element1], [element2], and [element3] in it. */ +@Suppress("UNCHECKED_CAST") +public fun scatterSetOf(element1: E, element2: E, element3: E): ScatterSet = + mutableScatterSetOf(element1, element2, element3) + +/** Returns a new read-only [ScatterSet] with only [elements] in it. */ +@Suppress("UNCHECKED_CAST") +public fun scatterSetOf(vararg elements: E): ScatterSet = + MutableScatterSet(elements.size).apply { plusAssign(elements) } + +/** Returns a new [MutableScatterSet]. */ +public fun mutableScatterSetOf(): MutableScatterSet = MutableScatterSet() + +/** Returns a new [MutableScatterSet] with only [element1] in it. */ +public fun mutableScatterSetOf(element1: E): MutableScatterSet = + MutableScatterSet(1).apply { plusAssign(element1) } + +/** Returns a new [MutableScatterSet] with only [element1] and [element2] in it. */ +public fun mutableScatterSetOf(element1: E, element2: E): MutableScatterSet = + MutableScatterSet(2).apply { + plusAssign(element1) + plusAssign(element2) + } + +/** Returns a new [MutableScatterSet] with only [element1], [element2], and [element3] in it. */ +public fun mutableScatterSetOf(element1: E, element2: E, element3: E): MutableScatterSet = + MutableScatterSet(3).apply { + plusAssign(element1) + plusAssign(element2) + plusAssign(element3) + } + +/** Returns a new [MutableScatterSet] with the specified contents. */ +public fun mutableScatterSetOf(vararg elements: E): MutableScatterSet = + MutableScatterSet(elements.size).apply { plusAssign(elements) } + +/** + * [ScatterSet] is a container with a [Set]-like interface based on a flat hash table + * implementation. The underlying implementation is designed to avoid all allocations on insertion, + * removal, retrieval, and iteration. Allocations may still happen on insertion when the underlying + * storage needs to grow to accommodate newly added elements to the set. + * + * This implementation makes no guarantee as to the order of the elements, nor does it make + * guarantees that the order remains constant over time. + * + * Though [ScatterSet] offers a read-only interface, it is always backed by a [MutableScatterSet]. + * Read operations alone are thread-safe. However, any mutations done through the backing + * [MutableScatterSet] while reading on another thread are not safe and the developer must protect + * the set from such changes during read operations. + * + * **Note**: when a [Set] is absolutely necessary, you can use the method [asSet] to create a thin + * wrapper around a [ScatterSet]. Please refer to [asSet] for more details and caveats. + * + * @see [MutableScatterSet] + */ +public sealed class ScatterSet { + // NOTE: Our arrays are marked internal to implement inlined forEach{} + // The backing array for the metadata bytes contains + // `capacity + 1 + ClonedMetadataCount` elements, including when + // the set is empty (see [EmptyGroup]). + @PublishedApi @JvmField internal var metadata: LongArray = EmptyGroup + + @PublishedApi @JvmField internal var elements: Array = EMPTY_OBJECTS + + // We use a backing field for capacity to avoid invokevirtual calls + // every time we need to look at the capacity + @JvmField internal var _capacity: Int = 0 + + /** + * Returns the number of elements that can be stored in this set without requiring internal + * storage reallocation. + */ + @get:IntRange(from = 0) + public val capacity: Int + get() = _capacity + + // We use a backing field for capacity to avoid invokevirtual calls + // every time we need to look at the size + @JvmField internal var _size: Int = 0 + + /** Returns the number of elements in this set. */ + @get:IntRange(from = 0) + public val size: Int + get() = _size + + /** Returns `true` if this set has at least one element. */ + public fun any(): Boolean = _size != 0 + + /** Returns `true` if this set has no elements. */ + public fun none(): Boolean = _size == 0 + + /** Indicates whether this set is empty. */ + public fun isEmpty(): Boolean = _size == 0 + + /** Returns `true` if this set is not empty. */ + public fun isNotEmpty(): Boolean = _size != 0 + + /** + * Returns the first element in the collection. + * + * @throws NoSuchElementException if the collection is empty + */ + public inline fun first(): E { + forEach { + return it + } + throw NoSuchElementException("The ScatterSet is empty") + } + + /** + * Returns the first element in the collection for which [predicate] returns `true` + * + * @param predicate called with each element until it returns `true`. + * @return The element for which [predicate] returns `true`. + * @throws NoSuchElementException if [predicate] returns `false` for all elements or the + * collection is empty. + */ + @OptIn(ExperimentalContracts::class) + public inline fun first(predicate: (element: E) -> Boolean): E { + contract { callsInPlace(predicate) } + forEach { if (predicate(it)) return it } + throw NoSuchElementException("Could not find a match") + } + + /** + * Returns the first element in the collection for which [predicate] returns `true` or `null` if + * there are no elements that match [predicate]. + * + * @param predicate called with each element until it returns `true`. + * @return The element for which [predicate] returns `true` or `null` if there are no elements + * in the set or [predicate] returned `false` for every element in the set. + */ + public inline fun firstOrNull(predicate: (element: E) -> Boolean): E? { + contract { callsInPlace(predicate) } + forEach { if (predicate(it)) return it } + return null + } + + /** Iterates over every element stored in this set by invoking the specified [block] lambda. */ + @PublishedApi + internal inline fun forEachIndex(block: (index: Int) -> Unit) { + contract { callsInPlace(block) } + val m = metadata + val lastIndex = m.size - 2 // We always have 0 or at least 2 elements + + for (i in 0..lastIndex) { + var slot = m[i] + if (slot.maskEmptyOrDeleted() != BitmaskMsb) { + // Branch-less if (i == lastIndex) 7 else 8 + // i - lastIndex returns a negative value when i < lastIndex, + // so 1 is set as the MSB. By inverting and shifting we get + // 0 when i < lastIndex, 1 otherwise. + val bitCount = 8 - ((i - lastIndex).inv() ushr 31) + for (j in 0 until bitCount) { + if (isFull(slot and 0xFFL)) { + val index = (i shl 3) + j + block(index) + } + slot = slot shr 8 + } + if (bitCount != 8) return + } + } + } + + /** + * Iterates over every element stored in this set by invoking the specified [block] lambda. + * + * @param block called with each element in the set + */ + public inline fun forEach(block: (element: E) -> Unit) { + contract { callsInPlace(block) } + val k = elements + + forEachIndex { index -> @Suppress("UNCHECKED_CAST") block(k[index] as E) } + } + + /** + * Returns true if all elements match the given [predicate]. If there are no elements in the + * set, `true` is returned. + * + * @param predicate called for elements in the set to determine if it returns return `true` for + * all elements. + */ + public inline fun all(predicate: (element: E) -> Boolean): Boolean { + contract { callsInPlace(predicate) } + forEach { element -> if (!predicate(element)) return false } + return true + } + + /** + * Returns true if at least one element matches the given [predicate]. + * + * @param predicate called for elements in the set to determine if it returns `true` for any + * elements. + */ + public inline fun any(predicate: (element: E) -> Boolean): Boolean { + contract { callsInPlace(predicate) } + forEach { element -> if (predicate(element)) return true } + return false + } + + /** Returns the number of elements in this set. */ + @IntRange(from = 0) public fun count(): Int = size + + /** + * Returns the number of elements matching the given [predicate]. + * + * @param predicate Called for all elements in the set to count the number for which it returns + * `true`. + */ + @IntRange(from = 0) + public inline fun count(predicate: (element: E) -> Boolean): Int { + contract { callsInPlace(predicate) } + var count = 0 + forEach { element -> if (predicate(element)) count++ } + return count + } + + /** + * Returns true if the specified [element] is present in this hash set, false otherwise. + * + * @param element The element to look for in this set + */ + public operator fun contains(element: E): Boolean = findElementIndex(element) >= 0 + + /** + * Creates a String from the elements separated by [separator] and using [prefix] before and + * [postfix] after, if supplied. + * + * When a non-negative value of [limit] is provided, a maximum of [limit] items are used to + * generate the string. If the collection holds more than [limit] items, the string is + * terminated with [truncated]. + * + * [transform] may be supplied to convert each element to a custom String. + */ + @JvmOverloads + public fun joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", // I know this should be suffix, but this is kotlin's name + limit: Int = -1, + truncated: CharSequence = "...", + transform: ((E) -> CharSequence)? = null + ): String = buildString { + append(prefix) + var index = 0 + this@ScatterSet.forEach { element -> + if (index == limit) { + append(truncated) + return@buildString + } + if (index != 0) { + append(separator) + } + if (transform == null) { + append(element) + } else { + append(transform(element)) + } + index++ + } + append(postfix) + } + + /** + * Returns the hash code value for this set. The hash code of a set is defined to be the sum of + * the hash codes of the elements in the set, where the hash code of a null element is defined + * to be zero + */ + public override fun hashCode(): Int { + var hash = 0 + + forEach { element -> hash += element.hashCode() } + + return hash + } + + /** + * Compares the specified object [other] with this hash set for equality. The two objects are + * considered equal if [other]: + * - Is a [ScatterSet] + * - Has the same [size] as this set + * - Contains elements equal to this set's elements + */ + public override fun equals(other: Any?): Boolean { + if (other === this) { + return true + } + + if (other !is ScatterSet<*>) { + return false + } + if (other.size != size) { + return false + } + + @Suppress("UNCHECKED_CAST") val o = other as ScatterSet + + forEach { element -> + if (element !in o) { + return false + } + } + + return true + } + + /** + * Returns a string representation of this set. The set is denoted in the string by the `[]`. + * Each element is separated by `, `. + */ + override fun toString(): String = + joinToString(prefix = "[", postfix = "]") { element -> + if (element === this) { + "(this)" + } else { + element.toString() + } + } + + /** + * Scans the set to find the index in the backing arrays of the specified [element]. Returns -1 + * if the element is not present. + */ + internal inline fun findElementIndex(element: E): Int { + val hash = hash(element) + val hash2 = h2(hash) + + val probeMask = _capacity + var probeOffset = h1(hash) and probeMask + var probeIndex = 0 + while (true) { + val g = group(metadata, probeOffset) + var m = g.match(hash2) + while (m.hasNext()) { + val index = (probeOffset + m.get()) and probeMask + if (elements[index] == element) { + return index + } + m = m.next() + } + + if (g.maskEmpty() != 0L) { + break + } + + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + + return -1 + } + + /** + * Wraps this [ScatterSet] with a [Set] interface. The [Set] is backed by the [ScatterSet], so + * changes to the [ScatterSet] are reflected in the [Set]. If the [ScatterSet] is modified while + * an iteration over the [Set] is in progress, the results of the iteration are undefined. + * + * **Note**: while this method is useful to use this [ScatterSet] with APIs accepting [Set] + * interfaces, it is less efficient to do so than to use [ScatterSet]'s APIs directly. While the + * [Set] implementation returned by this method tries to be as efficient as possible, the + * semantics of [Set] may require the allocation of temporary objects for access and iteration. + */ + public fun asSet(): Set = SetWrapper() + + internal open inner class SetWrapper : Set { + override val size: Int + get() = this@ScatterSet._size + + override fun containsAll(elements: Collection): Boolean { + elements.forEach { element -> + if (!this@ScatterSet.contains(element)) { + return false + } + } + return true + } + + @Suppress("KotlinOperator") + override fun contains(element: E): Boolean { + return this@ScatterSet.contains(element) + } + + override fun isEmpty(): Boolean = this@ScatterSet.isEmpty() + + override fun iterator(): Iterator { + return iterator { this@ScatterSet.forEach { element -> yield(element) } } + } + } +} + +/** + * [MutableScatterSet] is a container with a [MutableSet]-like interface based on a flat hash table + * implementation. The underlying implementation is designed to avoid all allocations on insertion, + * removal, retrieval, and iteration. Allocations may still happen on insertion when the underlying + * storage needs to grow to accommodate newly added elements to the set. + * + * This implementation makes no guarantee as to the order of the elements stored, nor does it make + * guarantees that the order remains constant over time. + * + * This implementation is not thread-safe: if multiple threads access this container concurrently, + * and one or more threads modify the structure of the set (insertion or removal for instance), the + * calling code must provide the appropriate synchronization. Concurrent reads are however safe. + * + * **Note**: when a [Set] is absolutely necessary, you can use the method [asSet] to create a thin + * wrapper around a [MutableScatterSet]. Please refer to [asSet] for more details and caveats. + * + * **Note**: when a [MutableSet] is absolutely necessary, you can use the method [asMutableSet] to + * create a thin wrapper around a [MutableScatterSet]. Please refer to [asMutableSet] for more + * details and caveats. + * + * @param initialCapacity The initial desired capacity for this container. the container will honor + * this value by guaranteeing its internal structures can hold that many elements without + * requiring any allocations. The initial capacity can be set to 0. + * @constructor Creates a new [MutableScatterSet] + * @see Set + */ +public class MutableScatterSet(initialCapacity: Int = DefaultScatterCapacity) : ScatterSet() { + // Number of elements we can add before we need to grow + private var growthLimit = 0 + + init { + requirePrecondition(initialCapacity >= 0) { "Capacity must be a positive value." } + initializeStorage(unloadedCapacity(initialCapacity)) + } + + private fun initializeStorage(initialCapacity: Int) { + val newCapacity = + if (initialCapacity > 0) { + // Since we use longs for storage, our capacity is never < 7, enforce + // it here. We do have a special case for 0 to create small empty maps + maxOf(7, normalizeCapacity(initialCapacity)) + } else { + 0 + } + _capacity = newCapacity + initializeMetadata(newCapacity) + elements = arrayOfNulls(newCapacity) + } + + private fun initializeMetadata(capacity: Int) { + metadata = + if (capacity == 0) { + EmptyGroup + } else { + // Round up to the next multiple of 8 and find how many longs we need + val size = (((capacity + 1 + ClonedMetadataCount) + 7) and 0x7.inv()) shr 3 + LongArray(size).apply { fill(AllEmpty) } + } + writeRawMetadata(metadata, capacity, Sentinel) + initializeGrowth() + } + + private fun initializeGrowth() { + growthLimit = loadedCapacity(capacity) - _size + } + + /** + * Adds the specified element to the set. + * + * @param element The element to add to the set. + * @return `true` if the element has been added or `false` if the element is already contained + * within the set. + */ + public fun add(element: E): Boolean { + val oldSize = size + val index = findAbsoluteInsertIndex(element) + elements[index] = element + return size != oldSize + } + + /** + * Adds the specified element to the set. + * + * @param element The element to add to the set. + */ + public operator fun plusAssign(element: E) { + val index = findAbsoluteInsertIndex(element) + elements[index] = element + } + + /** + * Adds all the [elements] into this set. + * + * @param elements An array of elements to add to the set. + * @return `true` if any of the specified elements were added to the collection, `false` if the + * collection was not modified. + */ + public fun addAll(@Suppress("ArrayReturn") elements: Array): Boolean { + val oldSize = size + plusAssign(elements) + return oldSize != size + } + + /** + * Adds all the [elements] into this set. + * + * @param elements Iterable elements to add to the set. + * @return `true` if any of the specified elements were added to the collection, `false` if the + * collection was not modified. + */ + public fun addAll(elements: Iterable): Boolean { + val oldSize = size + plusAssign(elements) + return oldSize != size + } + + /** + * Adds all the [elements] into this set. + * + * @param elements The sequence of elements to add to the set. + * @return `true` if any of the specified elements were added to the collection, `false` if the + * collection was not modified. + */ + public fun addAll(elements: Sequence): Boolean { + val oldSize = size + plusAssign(elements) + return oldSize != size + } + + /** + * Adds all the elements in the [elements] set into this set. + * + * @param elements A [ScatterSet] whose elements are to be added to the set + * @return `true` if any of the specified elements were added to the collection, `false` if the + * collection was not modified. + */ + public fun addAll(elements: ScatterSet): Boolean { + val oldSize = size + plusAssign(elements) + return oldSize != size + } + + /** + * Adds all the elements in the [elements] set into this set. + * + * @param elements An [ObjectList] whose elements are to be added to the set + * @return `true` if any of the specified elements were added to the collection, `false` if the + * collection was not modified. + */ + public fun addAll(elements: ObjectList): Boolean { + val oldSize = size + plusAssign(elements) + return oldSize != size + } + + /** + * Adds all the [elements] into this set. + * + * @param elements An array of elements to add to the set. + */ + public operator fun plusAssign(@Suppress("ArrayReturn") elements: Array) { + elements.forEach { element -> plusAssign(element) } + } + + /** + * Adds all the [elements] into this set. + * + * @param elements Iterable elements to add to the set. + */ + public operator fun plusAssign(elements: Iterable) { + elements.forEach { element -> plusAssign(element) } + } + + /** + * Adds all the [elements] into this set. + * + * @param elements The sequence of elements to add to the set. + */ + public operator fun plusAssign(elements: Sequence) { + elements.forEach { element -> plusAssign(element) } + } + + /** + * Adds all the elements in the [elements] set into this set. + * + * @param elements A [ScatterSet] whose elements are to be added to the set + */ + public operator fun plusAssign(elements: ScatterSet) { + elements.forEach { element -> plusAssign(element) } + } + + /** + * Adds all the elements in the [elements] set into this set. + * + * @param elements An [ObjectList] whose elements are to be added to the set + */ + public operator fun plusAssign(elements: ObjectList) { + elements.forEach { element -> plusAssign(element) } + } + + /** + * Removes the specified [element] from the set. + * + * @param element The element to be removed from the set. + * @return `true` if the [element] was present in the set, or `false` if it wasn't present + * before removal. + */ + public fun remove(element: E): Boolean { + val index = findElementIndex(element) + val exists = index >= 0 + if (exists) { + removeElementAt(index) + } + return exists + } + + /** + * Removes the specified [element] from the set if it is present. + * + * @param element The element to be removed from the set. + */ + public operator fun minusAssign(element: E) { + val index = findElementIndex(element) + if (index >= 0) { + removeElementAt(index) + } + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements An array of elements to be removed from the set. + * @return `true` if the set was changed or `false` if none of the elements were present. + */ + public fun removeAll(@Suppress("ArrayReturn") elements: Array): Boolean { + val oldSize = size + minusAssign(elements) + return oldSize != size + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements A sequence of elements to be removed from the set. + * @return `true` if the set was changed or `false` if none of the elements were present. + */ + public fun removeAll(elements: Sequence): Boolean { + val oldSize = size + minusAssign(elements) + return oldSize != size + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements A Iterable of elements to be removed from the set. + * @return `true` if the set was changed or `false` if none of the elements were present. + */ + public fun removeAll(elements: Iterable): Boolean { + val oldSize = size + minusAssign(elements) + return oldSize != size + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements A [ScatterSet] whose elements should be removed from the set. + * @return `true` if the set was changed or `false` if none of the elements were present. + */ + public fun removeAll(elements: ScatterSet): Boolean { + val oldSize = size + minusAssign(elements) + return oldSize != size + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements An [ObjectList] whose elements should be removed from the set. + * @return `true` if the set was changed or `false` if none of the elements were present. + */ +// public fun removeAll(elements: ObjectList): Boolean { +// val oldSize = size +// minusAssign(elements) +// return oldSize != size +// } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements An array of elements to be removed from the set. + */ + public operator fun minusAssign(@Suppress("ArrayReturn") elements: Array) { + elements.forEach { element -> minusAssign(element) } + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements A sequence of elements to be removed from the set. + */ + public operator fun minusAssign(elements: Sequence) { + elements.forEach { element -> minusAssign(element) } + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements A Iterable of elements to be removed from the set. + */ + public operator fun minusAssign(elements: Iterable) { + elements.forEach { element -> minusAssign(element) } + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements A [ScatterSet] whose elements should be removed from the set. + */ + public operator fun minusAssign(elements: ScatterSet) { + elements.forEach { element -> minusAssign(element) } + } + + /** + * Removes the specified [elements] from the set, if present. + * + * @param elements An [ObjectList] whose elements should be removed from the set. + */ + public operator fun minusAssign(elements: ObjectList) { + elements.forEach { element -> minusAssign(element) } + } + + /** Removes any values for which the specified [predicate] returns true. */ + public inline fun removeIf(predicate: (E) -> Boolean) { + val elements = elements + forEachIndex { index -> + @Suppress("UNCHECKED_CAST") + if (predicate(elements[index] as E)) { + removeElementAt(index) + } + } + } + + @PublishedApi + internal fun removeElementAt(index: Int) { + _size -= 1 + + // TODO: We could just mark the element as empty if there's a group + // window around this element that was already empty + writeMetadata(metadata, _capacity, index, Deleted) + elements[index] = null + } + + /** Removes all elements from this set. */ + public fun clear() { + _size = 0 + if (metadata !== EmptyGroup) { + metadata.fill(AllEmpty) + writeRawMetadata(metadata, _capacity, Sentinel) + } + elements.fill(null, 0, _capacity) + initializeGrowth() + } + + /** + * Scans the set to find the index at which we can store the given [element]. If the element + * already exists in the set, its index will be returned, otherwise the index of an empty slot + * will be returned. Calling this function may cause the internal storage to be reallocated if + * the set is full. + */ + private fun findAbsoluteInsertIndex(element: E): Int { + val hash = hash(element) + val hash1 = h1(hash) + val hash2 = h2(hash) + + val probeMask = _capacity + var probeOffset = hash1 and probeMask + var probeIndex = 0 + + while (true) { + val g = group(metadata, probeOffset) + var m = g.match(hash2) + while (m.hasNext()) { + val index = (probeOffset + m.get()) and probeMask + if (elements[index] == element) { + return index + } + m = m.next() + } + + if (g.maskEmpty() != 0L) { + break + } + + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + + var index = findFirstAvailableSlot(hash1) + if (growthLimit == 0 && !isDeleted(metadata, index)) { + adjustStorage() + index = findFirstAvailableSlot(hash1) + } + + _size += 1 + growthLimit -= if (isEmpty(metadata, index)) 1 else 0 + writeMetadata(metadata, _capacity, index, hash2.toLong()) + + return index + } + + /** + * Finds the first empty or deleted slot in the set in which we can store an element without + * resizing the internal storage. + */ + private fun findFirstAvailableSlot(hash1: Int): Int { + val probeMask = _capacity + var probeOffset = hash1 and probeMask + var probeIndex = 0 + while (true) { + val g = group(metadata, probeOffset) + val m = g.maskEmptyOrDeleted() + if (m != 0L) { + return (probeOffset + m.lowestBitSet()) and probeMask + } + probeIndex += GroupWidth + probeOffset = (probeOffset + probeIndex) and probeMask + } + } + + /** + * Trims this [MutableScatterSet]'s storage so it is sized appropriately to hold the current + * elements. + * + * Returns the number of empty elements removed from this set's storage. Returns 0 if no + * trimming is necessary or possible. + */ + @IntRange(from = 0) + public fun trim(): Int { + val previousCapacity = _capacity + val newCapacity = normalizeCapacity(unloadedCapacity(_size)) + if (newCapacity < previousCapacity) { + resizeStorage(newCapacity) + return previousCapacity - _capacity + } + return 0 + } + + /** + * Grow internal storage if necessary. This function can instead opt to remove deleted elements + * from the set to avoid an expensive reallocation of the underlying storage. This "rehash in + * place" occurs when the current size is <= 25/32 of the set capacity. The choice of 25/32 is + * detailed in the implementation of abseil's `raw_hash_map`. + */ + internal fun adjustStorage() { // Internal to prevent inlining + if (_capacity > GroupWidth && _size.toULong() * 32UL <= _capacity.toULong() * 25UL) { + dropDeletes() + } else { + resizeStorage(nextCapacity(_capacity)) + } + } + + // Internal to prevent inlining + internal fun dropDeletes() { + val metadata = metadata + val capacity = _capacity + val elements = elements + + // Converts Sentinel and Deleted to Empty, and Full to Deleted + convertMetadataForCleanup(metadata, capacity) + + var swapIndex = -1 + var index = 0 + + // Drop deleted items and re-hashes surviving entries + while (index != capacity) { + var m = readRawMetadata(metadata, index) + // Formerly Deleted entry, we can use it as a swap spot + if (m == Empty) { + swapIndex = index + index++ + continue + } + + // Formerly Full entries are now marked Deleted. If we see an + // entry that's not marked Deleted, we can ignore it completely + if (m != Deleted) { + index++ + continue + } + + val hash = hash(elements[index]) + val hash1 = h1(hash) + val targetIndex = findFirstAvailableSlot(hash1) + + // Test if the current index (i) and the new index (targetIndex) fall + // within the same group based on the hash. If the group doesn't change, + // we don't move the entry + val probeOffset = hash1 and capacity + val newProbeIndex = ((targetIndex - probeOffset) and capacity) / GroupWidth + val oldProbeIndex = ((index - probeOffset) and capacity) / GroupWidth + + if (newProbeIndex == oldProbeIndex) { + val hash2 = h2(hash) + writeRawMetadata(metadata, index, hash2.toLong()) + + // Copies the metadata into the clone area + metadata[metadata.lastIndex] = + (Empty shl 56) or (metadata[0] and 0x00ffffff_ffffffffL) + + index++ + continue + } + + m = readRawMetadata(metadata, targetIndex) + if (m == Empty) { + // The target is empty so we can transfer directly + val hash2 = h2(hash) + writeRawMetadata(metadata, targetIndex, hash2.toLong()) + writeRawMetadata(metadata, index, Empty) + + elements[targetIndex] = elements[index] + elements[index] = null + + swapIndex = index + } else /* m == Deleted */ { + // The target isn't empty so we use an empty slot denoted by + // swapIndex to perform the swap + val hash2 = h2(hash) + writeRawMetadata(metadata, targetIndex, hash2.toLong()) + + if (swapIndex == -1) { + swapIndex = findEmptySlot(metadata, index + 1, capacity) + } + + elements[swapIndex] = elements[targetIndex] + elements[targetIndex] = elements[index] + elements[index] = elements[swapIndex] + + // Since we exchanged two slots we must repeat the process with + // element we just moved in the current location + index-- + } + + // Copies the metadata into the clone area + metadata[metadata.lastIndex] = (Empty shl 56) or (metadata[0] and 0x00ffffff_ffffffffL) + + index++ + } + + initializeGrowth() + } + + // Internal to prevent inlining + internal fun resizeStorage(newCapacity: Int) { + val previousMetadata = metadata + val previousElements = elements + val previousCapacity = _capacity + + initializeStorage(newCapacity) + + val newMetadata = metadata + val newElements = elements + val capacity = _capacity + + for (i in 0 until previousCapacity) { + if (isFull(previousMetadata, i)) { + val previousElement = previousElements[i] + val hash = hash(previousElement) + val index = findFirstAvailableSlot(h1(hash)) + + writeMetadata(newMetadata, capacity, index, h2(hash).toLong()) + newElements[index] = previousElement + } + } + } + + /** + * Wraps this [ScatterSet] with a [MutableSet] interface. The [MutableSet] is backed by the + * [ScatterSet], so changes to the [ScatterSet] are reflected in the [MutableSet] and + * vice-versa. If the [ScatterSet] is modified while an iteration over the [MutableSet] is in + * progress (and vice- versa), the results of the iteration are undefined. + * + * **Note**: while this method is useful to use this [MutableScatterSet] with APIs accepting + * [MutableSet] interfaces, it is less efficient to do so than to use [MutableScatterSet]'s APIs + * directly. While the [MutableSet] implementation returned by this method tries to be as + * efficient as possible, the semantics of [MutableSet] may require the allocation of temporary + * objects for access and iteration. + */ + public fun asMutableSet(): MutableSet = MutableSetWrapper() + + private inner class MutableSetWrapper : SetWrapper(), MutableSet { + override fun add(element: E): Boolean = this@MutableScatterSet.add(element) + + override fun addAll(elements: Collection): Boolean = + this@MutableScatterSet.addAll(elements) + + override fun clear() { + this@MutableScatterSet.clear() + } + + override fun iterator(): MutableIterator = + object : MutableIterator { + var current = -1 + val iterator = iterator { + this@MutableScatterSet.forEachIndex { index -> + current = index + @Suppress("UNCHECKED_CAST") yield(elements[index] as E) + } + } + + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): E = iterator.next() + + override fun remove() { + if (current != -1) { + this@MutableScatterSet.removeElementAt(current) + current = -1 + } + } + } + + override fun remove(element: E): Boolean = this@MutableScatterSet.remove(element) + + override fun retainAll(elements: Collection): Boolean { + var changed = false + this@MutableScatterSet.forEachIndex { index -> + @Suppress("UNCHECKED_CAST") + val element = this@MutableScatterSet.elements[index] as E + if (element !in elements) { + this@MutableScatterSet.removeElementAt(index) + changed = true + } + } + return changed + } + + override fun removeAll(elements: Collection): Boolean { + val oldSize = this@MutableScatterSet.size + for (element in elements) { + this@MutableScatterSet -= element + } + return oldSize != this@MutableScatterSet.size + } + } +} diff --git a/core/src/commonMain/kotlin/androidx/collection/internal/ContainerHelpers.kt b/core/src/commonMain/kotlin/androidx/collection/internal/ContainerHelpers.kt new file mode 100644 index 0000000..80710fc --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/collection/internal/ContainerHelpers.kt @@ -0,0 +1,89 @@ +/* + * 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. + */ + +package androidx.collection.internal + +import kotlin.jvm.JvmField + +@JvmField +internal val EMPTY_INTS = IntArray(0) + +@JvmField +internal val EMPTY_LONGS = LongArray(0) + +@JvmField +internal val EMPTY_OBJECTS = arrayOfNulls(0) + +internal fun idealIntArraySize(need: Int): Int { + return idealByteArraySize(need * 4) / 4 +} + +internal fun idealLongArraySize(need: Int): Int { + return idealByteArraySize(need * 8) / 8 +} + +internal fun idealByteArraySize(need: Int): Int { + for (i in 4..31) { + if (need <= (1 shl i) - 12) { + return (1 shl i) - 12 + } + } + return need +} + +// null-safe object equals, which is equivalent to `a == b || (a != null && a.equals(b));` in Java. +internal fun equal(a: Any?, b: Any?): Boolean { + return a == b +} + +// Same as Arrays.binarySearch(), but doesn't do any argument validation. Very importantly, also +// guarantees consistent result on duplicate values, which is required for indexOfNull in +// SimpleArrayMap, because the mapped hash for `null` is 0, the same as default initialized value. +internal fun binarySearch(array: IntArray, size: Int, value: Int): Int { + var lo = 0 + var hi = size - 1 + while (lo <= hi) { + val mid = lo + hi ushr 1 + val midVal = array[mid] + if (midVal < value) { + lo = mid + 1 + } else if (midVal > value) { + hi = mid - 1 + } else { + return mid // value found + } + } + return lo.inv() // value not present +} + +// Same as Arrays.binarySearch(), but doesn't do any argument validation. Very importantly, also +// guarantees consistent result on duplicate values. +internal fun binarySearch(array: LongArray, size: Int, value: Long): Int { + var lo = 0 + var hi = size - 1 + while (lo <= hi) { + val mid = lo + hi ushr 1 + val midVal = array[mid] + if (midVal < value) { + lo = mid + 1 + } else if (midVal > value) { + hi = mid - 1 + } else { + return mid // value found + } + } + return lo.inv() // value not present +} diff --git a/core/src/commonMain/kotlin/androidx/collection/internal/RuntimeHelpers.kt b/core/src/commonMain/kotlin/androidx/collection/internal/RuntimeHelpers.kt new file mode 100644 index 0000000..90ec883 --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/collection/internal/RuntimeHelpers.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.collection.internal + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +// This function exists so we do *not* inline the throw. It keeps +// the call site much smaller and since it's the slow path anyway, +// we don't mind the extra function call +internal fun throwIllegalStateException(message: String) { + throw IllegalStateException(message) +} + +// Like Kotlin's require() but without the .toString() call +@OptIn(ExperimentalContracts::class) +internal inline fun checkPrecondition(value: Boolean, lazyMessage: () -> String) { + contract { returns() implies value } + if (!value) { + throwIllegalStateException(lazyMessage()) + } +} + +internal fun throwIllegalArgumentException(message: String) { + throw IllegalArgumentException(message) +} + +// Like Kotlin's require() but without the .toString() call +@Suppress("BanInlineOptIn") // same opt-in as using Kotlin's require() +@OptIn(ExperimentalContracts::class) +internal inline fun requirePrecondition(value: Boolean, lazyMessage: () -> String) { + contract { returns() implies value } + if (!value) { + throwIllegalArgumentException(lazyMessage()) + } +} diff --git a/core/src/commonMain/kotlin/androidx/compose/foundation/Expect.kt b/core/src/commonMain/kotlin/androidx/compose/foundation/Expect.kt deleted file mode 100644 index 2b9029b..0000000 --- a/core/src/commonMain/kotlin/androidx/compose/foundation/Expect.kt +++ /dev/null @@ -1,29 +0,0 @@ -// ktlint-disable filename - -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.foundation - -import kotlinx.coroutines.CancellationException - -/** - * Represents a platform-optimized cancellation exception. - * This allows us to configure exceptions separately on JVM and other platforms. - */ -internal expect abstract class CorePlatformOptimizedCancellationException( - message: String? = null -) : CancellationException diff --git a/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt new file mode 100644 index 0000000..aae444f --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt @@ -0,0 +1,1218 @@ +// File copied from Compose Foundation 1.7.0 +/* + * 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. + */ + +@file:OptIn(ExperimentalFoundationApi::class) + +package androidx.compose.foundation.gestures + +import androidx.collection.MutableObjectFloatMap +import androidx.collection.ObjectFloatMap +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.gestures.DragEvent.DragDelta +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireLayoutDirection +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity +import androidx.annotation.FloatRange +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sign +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Enable drag gestures between a set of predefined values. + * + * When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag + * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). + * When the drag ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [AnchoredDraggableState] will also be updated to the value + * corresponding to the new anchor. + * + * Dragging is constrained between the minimum and maximum anchors. + * + * @param state The associated [AnchoredDraggableState]. + * @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom + * drag will behave like bottom to top, and a left to right drag will behave like right to left. If + * not specified, this will be determined based on [orientation] and [LocalLayoutDirection]. + * @param orientation The orientation in which the [anchoredDraggable] can be dragged. + * @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + * @param overscrollEffect optional effect to dispatch any excess delta or velocity to. The excess + * delta or velocity are a result of dragging/flinging and reaching the bounds. If you provide an + * [overscrollEffect], make sure to apply [androidx.compose.foundation.overscroll] to render the + * effect as well. + * @param startDragImmediately when set to false, [draggable] will start dragging only when the + * gesture crosses the touchSlop. This is useful to prevent users from "catching" an animating + * widget when pressing on it. See [draggable] to learn more about startDragImmediately. + */ + +fun Modifier.anchoredDraggable( + state: AnchoredDraggableState, + reverseDirection: Boolean, + orientation: Orientation, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + overscrollEffect: OverscrollEffect? = null, + startDragImmediately: Boolean = state.isAnimationRunning +): Modifier = this then AnchoredDraggableElement( + state = state, + orientation = orientation, + enabled = enabled, + reverseDirection = reverseDirection, + interactionSource = interactionSource, + overscrollEffect = overscrollEffect, + startDragImmediately = startDragImmediately +) + +/** + * Enable drag gestures between a set of predefined values. + * + * When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag + * delta. If the [orientation] is set to [Orientation.Horizontal] and [LocalLayoutDirection]'s + * value is [LayoutDirection.Rtl], the drag deltas will be reversed. + * You should use this offset to move your content accordingly (see [Modifier.offset]). + * When the drag ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [AnchoredDraggableState] will also be updated to the value + * corresponding to the new anchor. + * + * Dragging is constrained between the minimum and maximum anchors. + * + * @param state The associated [AnchoredDraggableState]. + * @param orientation The orientation in which the [anchoredDraggable] can be dragged. + * @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + * @param overscrollEffect optional effect to dispatch any excess delta or velocity to. The excess + * delta or velocity are a result of dragging/flinging and reaching the bounds. If you provide an + * [overscrollEffect], make sure to apply [androidx.compose.foundation.overscroll] to render the + * effect as well. + * @param startDragImmediately when set to false, [draggable] will start dragging only when the + * gesture crosses the touchSlop. This is useful to prevent users from "catching" an animating + * widget when pressing on it. See [draggable] to learn more about startDragImmediately. + */ + +fun Modifier.anchoredDraggable( + state: AnchoredDraggableState, + orientation: Orientation, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + overscrollEffect: OverscrollEffect? = null, + startDragImmediately: Boolean = state.isAnimationRunning +): Modifier = this then AnchoredDraggableElement( + state = state, + orientation = orientation, + enabled = enabled, + reverseDirection = null, + interactionSource = interactionSource, + overscrollEffect = overscrollEffect, + startDragImmediately = startDragImmediately +) + +private class AnchoredDraggableElement( + private val state: AnchoredDraggableState, + private val orientation: Orientation, + private val enabled: Boolean, + private val reverseDirection: Boolean?, + private val interactionSource: MutableInteractionSource?, + private val startDragImmediately: Boolean, + private val overscrollEffect: OverscrollEffect?, +) : ModifierNodeElement>() { + override fun create() = AnchoredDraggableNode( + state, + orientation, + enabled, + reverseDirection, + interactionSource, + overscrollEffect, + startDragImmediately, + ) + + override fun update(node: AnchoredDraggableNode) { + node.update( + state, + orientation, + enabled, + reverseDirection, + interactionSource, + overscrollEffect, + startDragImmediately + ) + } + + override fun hashCode(): Int { + var result = state.hashCode() + result = 31 * result + orientation.hashCode() + result = 31 * result + enabled.hashCode() + result = 31 * result + reverseDirection.hashCode() + result = 31 * result + interactionSource.hashCode() + result = 31 * result + startDragImmediately.hashCode() + result = 31 * result + overscrollEffect.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + + if (other !is AnchoredDraggableElement<*>) return false + + if (state != other.state) return false + if (orientation != other.orientation) return false + if (enabled != other.enabled) return false + if (reverseDirection != other.reverseDirection) return false + if (interactionSource != other.interactionSource) return false + if (startDragImmediately != other.startDragImmediately) return false + if (overscrollEffect != other.overscrollEffect) return false + + return true + } + + override fun InspectorInfo.inspectableProperties() { + name = "anchoredDraggable" + properties["state"] = state + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseDirection"] = reverseDirection + properties["interactionSource"] = interactionSource + properties["startDragImmediately"] = startDragImmediately + properties["overscrollEffect"] = overscrollEffect + } +} + +@OptIn(ExperimentalFoundationApi::class) +private class AnchoredDraggableNode( + private var state: AnchoredDraggableState, + private var orientation: Orientation, + enabled: Boolean, + private var reverseDirection: Boolean?, + interactionSource: MutableInteractionSource?, + private var overscrollEffect: OverscrollEffect?, + private var startDragImmediately: Boolean +) : DragGestureNode( + canDrag = AlwaysDrag, + enabled = enabled, + interactionSource = interactionSource, + orientationLock = orientation +) { + + private val isReverseDirection: Boolean + get() = when (reverseDirection) { + null -> requireLayoutDirection() == LayoutDirection.Rtl && + orientation == Orientation.Horizontal + else -> reverseDirection!! + } + + override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) { + state.anchoredDrag { + forEachDelta { dragDelta -> + if (overscrollEffect == null) { + dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().toFloat())) + } else { + overscrollEffect!!.applyToScroll( + delta = dragDelta.delta.reverseIfNeeded(), + source = NestedScrollSource.UserInput + ) { deltaForDrag -> + val dragOffset = state.newOffsetForDelta(deltaForDrag.toFloat()) + val consumedDelta = (dragOffset - state.requireOffset()).toOffset() + dragTo(dragOffset) + consumedDelta + } + } + } + } + } + + override fun onDragStarted(startedPosition: Offset) { } + + override fun onDragStopped(velocity: Velocity) { + if (!isAttached) return + coroutineScope.launch { + if (overscrollEffect == null) { + state.settle(velocity.reverseIfNeeded().toFloat()).toVelocity() + } else { + overscrollEffect!!.applyToFling( + velocity = velocity.reverseIfNeeded() + ) { availableVelocity -> + val consumed = state.settle(availableVelocity.toFloat()).toVelocity() + val currentOffset = state.requireOffset() + val minAnchor = state.anchors.minAnchor() + val maxAnchor = state.anchors.maxAnchor() + // return consumed velocity only if we are reaching the min/max anchors + if (currentOffset >= maxAnchor || currentOffset <= minAnchor) { + consumed + } else { + availableVelocity + } + } + } + } + } + + override fun startDragImmediately(): Boolean = startDragImmediately + + fun update( + state: AnchoredDraggableState, + orientation: Orientation, + enabled: Boolean, + reverseDirection: Boolean?, + interactionSource: MutableInteractionSource?, + overscrollEffect: OverscrollEffect?, + startDragImmediately: Boolean + ) { + var resetPointerInputHandling = false + + if (this.state != state) { + this.state = state + resetPointerInputHandling = true + } + if (this.orientation != orientation) { + this.orientation = orientation + resetPointerInputHandling = true + } + + if (this.reverseDirection != reverseDirection) { + this.reverseDirection = reverseDirection + resetPointerInputHandling = true + } + + this.startDragImmediately = startDragImmediately + this.overscrollEffect = overscrollEffect + + update( + enabled = enabled, + interactionSource = interactionSource, + shouldResetPointerInputHandling = resetPointerInputHandling, + orientationLock = orientation + ) + } + + private fun Float.toOffset() = Offset( + x = if (orientation == Orientation.Horizontal) this else 0f, + y = if (orientation == Orientation.Vertical) this else 0f, + ) + + private fun Float.toVelocity() = Velocity( + x = if (orientation == Orientation.Horizontal) this else 0f, + y = if (orientation == Orientation.Vertical) this else 0f, + ) + + private fun Velocity.toFloat() = + if (orientation == Orientation.Vertical) this.y else this.x + + private fun Offset.toFloat() = + if (orientation == Orientation.Vertical) this.y else this.x + + private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f + private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f +} + +private val AlwaysDrag: (PointerInputChange) -> Boolean = { true } + +/** + * Structure that represents the anchors of a [AnchoredDraggableState]. + * + * See the DraggableAnchors factory method to construct drag anchors using a default implementation. + */ + +interface DraggableAnchors { + + /** + * Get the anchor position for an associated [value] + * + * @param value The value to look up + * + * @return The position of the anchor, or [Float.NaN] if the anchor does not exist + */ + fun positionOf(value: T): Float + + /** + * Whether there is an anchor position associated with the [value] + * + * @param value The value to look up + * + * @return true if there is an anchor for this value, false if there is no anchor for this value + */ + fun hasAnchorFor(value: T): Boolean + + /** + * Find the closest anchor to the [position]. + * + * @param position The position to start searching from + * + * @return The closest anchor or null if the anchors are empty + */ + fun closestAnchor(position: Float): T? + + /** + * Find the closest anchor to the [position], in the specified direction. + * + * @param position The position to start searching from + * @param searchUpwards Whether to search upwards from the current position or downwards + * + * @return The closest anchor or null if the anchors are empty + */ + fun closestAnchor(position: Float, searchUpwards: Boolean): T? + + /** + * The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty. + */ + fun minAnchor(): Float + + /** + * The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty. + */ + fun maxAnchor(): Float + + /** + * Iterate over all the anchors and corresponding positions. + * + * @param block The action to invoke with the anchor and position + */ + fun forEach(block: (anchor: T, position: Float) -> Unit) + + /** + * The amount of anchors + */ + val size: Int +} + +/** + * [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and + * corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable + * [DraggableAnchors] instance later on. + */ + +class DraggableAnchorsConfig { + + internal val anchors = MutableObjectFloatMap() + + /** + * Set the anchor position for [this] anchor. + * + * @param position The anchor position. + */ + @Suppress("BuilderSetStyle") + infix fun T.at(position: Float) { + anchors[this] = position + } +} + +/** + * Create a new [DraggableAnchors] instance using a builder function. + * + * @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors + * @return A new [DraggableAnchors] instance with the anchor positions set by the `builder` + * function. + */ + +fun DraggableAnchors( + builder: DraggableAnchorsConfig.() -> Unit +): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().apply(builder).anchors) + +/** + * Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to + * a new value. + * + * @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the + * access to this scope. + */ + +interface AnchoredDragScope { + /** + * Assign a new value for an offset value for [AnchoredDraggableState]. + * + * @param newOffset new value for [AnchoredDraggableState.offset]. + * @param lastKnownVelocity last known velocity (if known) + */ + fun dragTo( + newOffset: Float, + lastKnownVelocity: Float = 0f + ) +} + +/** + * State of the [anchoredDraggable] modifier. + * Use the constructor overload with anchors if the anchors are defined in composition, or update + * the anchors using [updateAnchors]. + * + * This contains necessary information about any ongoing drag or animation and provides methods + * to change the state either immediately or by starting an animation. + * + * @param initialValue The initial value of the state. + * @param positionalThreshold The positional threshold, in px, to be used when calculating the + * target state while a drag is in progress and when settling after the drag 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. + * @param velocityThreshold The velocity threshold (in px 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. + * @param snapAnimationSpec The default animation spec that will be used to animate to a new state. + * @param decayAnimationSpec The animation spec that will be used when flinging with a large enough + * velocity to reach or cross the target state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. + */ +@Stable +class AnchoredDraggableState( + initialValue: T, + internal val positionalThreshold: (totalDistance: Float) -> Float, + internal val velocityThreshold: () -> Float, + val snapAnimationSpec: AnimationSpec, + val decayAnimationSpec: DecayAnimationSpec, + internal val confirmValueChange: (newValue: T) -> Boolean = { true } +) { + + /** + * Construct an [AnchoredDraggableState] instance with anchors. + * + * @param initialValue The initial value of the state. + * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. + * @param snapAnimationSpec The default animation spec that will be used to animate to a new + * state. + * @param decayAnimationSpec The animation spec that will be used when flinging with a large + * enough velocity to reach or cross the target state. + * @param confirmValueChange Optional callback invoked to confirm or veto a pending state + * change. + * @param positionalThreshold The positional threshold, in px, to be used when calculating the + * target state while a drag is in progress and when settling after the drag 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. + * @param velocityThreshold The velocity threshold (in px 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. + */ + + constructor( + initialValue: T, + anchors: DraggableAnchors, + positionalThreshold: (totalDistance: Float) -> Float, + velocityThreshold: () -> Float, + snapAnimationSpec: AnimationSpec, + decayAnimationSpec: DecayAnimationSpec, + confirmValueChange: (newValue: T) -> Boolean = { true } + ) : this( + initialValue, + positionalThreshold, + velocityThreshold, + snapAnimationSpec, + decayAnimationSpec, + confirmValueChange + ) { + this.anchors = anchors + trySnapTo(initialValue) + } + + private val dragMutex = MutatorMutex() + + /** + * The current value of the [AnchoredDraggableState]. + * + * That is the closest anchor point that the state has passed through. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * The value the [AnchoredDraggableState] is currently settled at. + * + * When progressing through multiple anchors, e.g. `A -> B -> C`, [settledValue] will stay the + * same until settled at an anchor, while [currentValue] will update to the closest anchor. + */ + var settledValue: T by mutableStateOf(initialValue) + private set + + /** + * The target value. This is the closest value to the current offset. If no interactions like + * animations or drags are in progress, this will be the current value. + */ + val targetValue: T by derivedStateOf { + dragTarget ?: run { + val currentOffset = offset + if (!currentOffset.isNaN()) { + anchors.closestAnchor(offset) ?: currentValue + } else currentValue + } + } + + /** + * The current offset, or [Float.NaN] if it has not been initialized yet. + * + * The offset will be initialized when the anchors are first set through [updateAnchors]. + * + * Strongly consider using [requireOffset] which will throw if the offset is read before it is + * initialized. This helps catch issues early in your workflow. + */ + var offset: Float by mutableFloatStateOf(Float.NaN) + private set + + /** + * Require the current offset. + * + * @see offset + * + * @throws IllegalStateException If the offset has not been initialized yet + */ + fun requireOffset(): Float { + check(!offset.isNaN()) { + "The offset was read before being initialized. Did you access the offset in a phase " + + "before layout, like effects or composition?" + } + return offset + } + + /** + * Whether an animation is currently in progress. + */ + val isAnimationRunning: Boolean get() = dragTarget != null + + /** + * The fraction of the offset between [from] and [to], as a fraction between [0f..1f], or 1f if + * [from] is equal to [to]. + * + * @param from The starting value used to calculate the distance + * @param to The end value used to calculate the distance + */ + @FloatRange(from = 0.0, to = 1.0) + fun progress(from: T, to: T): Float { + val fromOffset = anchors.positionOf(from) + val toOffset = anchors.positionOf(to) + val currentOffset = offset.coerceIn( + min(fromOffset, toOffset), // fromOffset might be > toOffset + max(fromOffset, toOffset) + ) + val fraction = (currentOffset - fromOffset) / (toOffset - fromOffset) + return if (!fraction.isNaN()) { + // If we are very close to 0f or 1f, we round to the closest + if (fraction < 1e-6f) 0f else if (fraction > 1 - 1e-6f) 1f else abs(fraction) + } else 1f + } + + /** + * The fraction of the progress going from [settledValue] to [targetValue], within [0f..1f] + * bounds, or 1f if the [AnchoredDraggableState] is in a settled state. + */ + @Deprecated( + message = "Use the progress function to query the progress between two specified " + + "anchors.", + replaceWith = ReplaceWith("progress(state.settledValue, state.targetValue)") + ) + @get:FloatRange(from = 0.0, to = 1.0) + val progress: Float by derivedStateOf(structuralEqualityPolicy()) { + val a = anchors.positionOf(settledValue) + val b = anchors.positionOf(targetValue) + val distance = abs(b - a) + if (!distance.isNaN() && 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 mutableFloatStateOf(0f) + private set + + private var dragTarget: T? by mutableStateOf(null) + + var anchors: DraggableAnchors by mutableStateOf(emptyDraggableAnchors()) + private set + + /** + * Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget], + * otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new + * anchors. + * + * If your anchors depend on the size of the layout, updateAnchors should be called in the + * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the + * state is set up within the same frame. + * For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to + * be called from side effects or layout. + * + * @param newAnchors The new anchors. + * @param newTarget The new target, by default the closest anchor or the current target if there + * are no anchors. + */ + fun updateAnchors( + newAnchors: DraggableAnchors, + newTarget: T = if (!offset.isNaN()) { + newAnchors.closestAnchor(offset) ?: targetValue + } else targetValue + ) { + if (anchors != newAnchors) { + anchors = newAnchors + // Attempt to snap. If nobody is holding the lock, we can immediately update the offset. + // If anybody is holding the lock, we send a signal to restart the ongoing work with the + // updated anchors. + val snapSuccessful = trySnapTo(newTarget) + if (!snapSuccessful) { + dragTarget = newTarget + } + } + } + + /** + * Find the closest anchor, taking into account the [velocityThreshold] and + * [positionalThreshold], and settle at it with an animation. + * + * If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and + * [positionalThreshold] will be the target. If the [velocity] is higher than the + * [velocityThreshold], the [positionalThreshold] will not be considered and the next + * anchor in the direction indicated by the sign of the [velocity] will be the target. + * + * Based on the [velocity], either [snapAnimationSpec] or [decayAnimationSpec] will be used + * to animate towards the target. + * + * @return The velocity consumed in the animation + */ + suspend fun settle(velocity: Float): Float { + val previousValue = this.currentValue + val targetValue = computeTarget( + offset = requireOffset(), + currentValue = previousValue, + velocity = velocity + ) + return if (confirmValueChange(targetValue)) { + animateToWithDecay(targetValue, velocity) + } else { + // If the user vetoed the state change, rollback to the previous state. + animateToWithDecay(previousValue, velocity) + } + } + + private fun computeTarget( + offset: Float, + currentValue: T, + velocity: Float + ): T { + val currentAnchors = anchors + val currentAnchorPosition = currentAnchors.positionOf(currentValue) + val velocityThresholdPx = velocityThreshold() + return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { + currentValue + } else { + if (abs(velocity) >= abs(velocityThresholdPx)) { + currentAnchors.closestAnchor( + offset, + sign(velocity) > 0 + )!! + } else { + val neighborAnchor = + currentAnchors.closestAnchor( + offset, + offset - currentAnchorPosition > 0 + )!! + val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor) + val distance = abs(currentAnchorPosition - neighborAnchorPosition) + val relativeThreshold = abs(positionalThreshold(distance)) + val relativePosition = abs(currentAnchorPosition - offset) + if (relativePosition <= relativeThreshold) currentValue else neighborAnchor + } + } + } + + private val anchoredDragScope = object : AnchoredDragScope { + var leftBound: T? = null + var rightBound: T? = null + var distance = Float.NaN + + override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { + val previousOffset = offset + offset = newOffset + lastVelocity = lastKnownVelocity + if (previousOffset.isNaN()) return + val isMovingForward = newOffset >= previousOffset + updateIfNeeded(isMovingForward) + } + + fun updateIfNeeded(isMovingForward: Boolean) { + updateBounds(isMovingForward) + val distanceToCurrentAnchor = abs(offset - anchors.positionOf(currentValue)) + val crossedThreshold = distanceToCurrentAnchor >= distance / 2f + if (crossedThreshold) { + val closestAnchor = (if (isMovingForward) rightBound else leftBound) ?: currentValue + if (confirmValueChange(closestAnchor)) { + currentValue = closestAnchor + } + } + } + + fun updateBounds(isMovingForward: Boolean) { + val currentAnchorPosition = anchors.positionOf(currentValue) + if (offset == currentAnchorPosition) { + val searchStartPosition = offset + (if (isMovingForward) 1f else -1f) + val closestExcludingCurrent = + anchors.closestAnchor(searchStartPosition, isMovingForward) ?: currentValue + if (isMovingForward) { + leftBound = currentValue + rightBound = closestExcludingCurrent + } else { + leftBound = closestExcludingCurrent + rightBound = currentValue + } + } else { + val closestLeft = anchors.closestAnchor(offset, false) ?: currentValue + val closestRight = anchors.closestAnchor(offset, true) ?: currentValue + leftBound = closestLeft + rightBound = closestRight + } + distance = abs(anchors.positionOf(leftBound!!) - anchors.positionOf(rightBound!!)) + } + } + + /** + * Call this function to take control of drag logic and perform anchored drag with the latest + * anchors. + * + * All actions that change the [offset] of this [AnchoredDraggableState] must be performed + * within an [anchoredDrag] block (even if they don't call any other methods on this object) + * in order to guarantee that mutual exclusion is enforced. + * + * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, the ongoing drag will be cancelled. + * + * If the [anchors] change while the [block] is being executed, it will be cancelled and + * re-executed with the latest anchors and target. This allows you to target the correct + * state. + * + * @param dragPriority of the drag operation + * @param block perform anchored drag given the current anchor provided + */ + suspend fun anchoredDrag( + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend AnchoredDragScope.(anchors: DraggableAnchors) -> Unit + ) { + dragMutex.mutate(dragPriority) { + restartable(inputs = { anchors }) { latestAnchors -> + anchoredDragScope.block(latestAnchors) + } + val closest = anchors.closestAnchor(offset) + if (closest != null) { + val closestAnchorOffset = anchors.positionOf(closest) + val isAtClosestAnchor = abs(offset - closestAnchorOffset) < 0.5f + if (isAtClosestAnchor && confirmValueChange.invoke(closest)) { + settledValue = closest + currentValue = closest + } + } + } + } + + /** + * Call this function to take control of drag logic and perform anchored drag with the latest + * anchors and target. + * + * All actions that change the [offset] of this [AnchoredDraggableState] must be performed + * within an [anchoredDrag] block (even if they don't call any other methods on this object) + * in order to guarantee that mutual exclusion is enforced. + * + * This overload allows the caller to hint the target value that this [anchoredDrag] is intended + * to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so + * consumers can reflect it in their UIs. + * + * If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being + * executed, it will be cancelled and re-executed with the latest anchors and target. This + * allows you to target the correct state. + * + * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, the ongoing drag will be cancelled. + * + * @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to + * @param dragPriority of the drag operation + * @param block perform anchored drag given the current anchor provided + */ + suspend fun anchoredDrag( + targetValue: T, + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend AnchoredDragScope.(anchor: DraggableAnchors, targetValue: T) -> Unit + ) { + if (anchors.hasAnchorFor(targetValue)) { + try { + dragMutex.mutate(dragPriority) { + dragTarget = targetValue + restartable( + inputs = { anchors to this@AnchoredDraggableState.targetValue } + ) { (anchors, latestTarget) -> + anchoredDragScope.block(anchors, latestTarget) + } + if (confirmValueChange(targetValue)) { + val latestTargetOffset = anchors.positionOf(targetValue) + anchoredDragScope.dragTo(latestTargetOffset, lastVelocity) + settledValue = targetValue + currentValue = targetValue + } + } + } finally { + dragTarget = null + } + } else { + if (confirmValueChange(targetValue)) { + settledValue = targetValue + currentValue = targetValue + } + } + } + + internal fun newOffsetForDelta(delta: Float) = + ((if (offset.isNaN()) 0f else offset) + delta) + .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) + + /** + * Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState]. + * + * @return The delta the consumed by the [AnchoredDraggableState] + */ + fun dispatchRawDelta(delta: Float): Float { + val newOffset = newOffsetForDelta(delta) + val oldOffset = if (offset.isNaN()) 0f else offset + offset = newOffset + return newOffset - oldOffset + } + + /** + * Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag + * transaction like a drag or an animation is progress. If there is another interaction in + * progress, the suspending [snapTo] overload needs to be used. + * + * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous + */ + private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate { + with(anchoredDragScope) { + val targetOffset = anchors.positionOf(targetValue) + if (!targetOffset.isNaN()) { + dragTo(targetOffset) + dragTarget = null + } + currentValue = targetValue + settledValue = targetValue + } + } + + companion object { + /** + * The default [Saver] implementation for [AnchoredDraggableState]. + */ + + fun Saver( + snapAnimationSpec: AnimationSpec, + decayAnimationSpec: DecayAnimationSpec, + positionalThreshold: (distance: Float) -> Float, + velocityThreshold: () -> Float, + confirmValueChange: (T) -> Boolean = { true }, + ) = Saver, T>( + save = { it.currentValue }, + restore = { + AnchoredDraggableState( + initialValue = it, + snapAnimationSpec = snapAnimationSpec, + decayAnimationSpec = decayAnimationSpec, + confirmValueChange = confirmValueChange, + positionalThreshold = positionalThreshold, + velocityThreshold = velocityThreshold + ) + } + ) + } +} + +/** + * Snap to a [targetValue] without any animation. + * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.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 AnchoredDraggableState.snapTo(targetValue: T) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) dragTo(targetOffset) + } +} + +private suspend fun AnchoredDraggableState.animateTo( + velocity: Float, + anchoredDragScope: AnchoredDragScope, + anchors: DraggableAnchors, + latestTarget: T +) { + with(anchoredDragScope) { + val targetOffset = anchors.positionOf(latestTarget) + var prev = if (offset.isNaN()) 0f else offset + if (!targetOffset.isNaN() && prev != targetOffset) { + debugLog { "Target animation is used" } + animate(prev, targetOffset, velocity, snapAnimationSpec) { 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. + dragTo(value, velocity) + prev = value + } + } + } +} + +/** + * Animate to a [targetValue]. + * If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.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 AnchoredDraggableState.animateTo(targetValue: T) { + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + animateTo(lastVelocity, this, anchors, latestTarget) + } +} + +/** + * Attempt to animate using decay Animation to a [targetValue]. If the [velocity] is high enough to + * get to the target offset, we'll use [AnchoredDraggableState.decayAnimationSpec] to get to that + * offset and return the consumed velocity. If the [velocity] is not high + * enough, we'll use [AnchoredDraggableState.snapAnimationSpec] to reach the target offset. + * + * If the [targetValue] is not in the set of anchors, [AnchoredDraggableState.currentValue] will be + * updated ro the [targetValue] without updating the offset. + * + * @throws CancellationException if the interaction interrupted bt another interaction like a + * gesture interaction or another programmatic interaction like [animateTo] or [snapTo] call. + * + * @param targetValue The target value of the animation + * @param velocity The velocity the animation should start with + * + * @return The velocity consumed in the animation + */ + +suspend fun AnchoredDraggableState.animateToWithDecay( + targetValue: T, + velocity: Float, +): Float { + var remainingVelocity = velocity + anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> + val targetOffset = anchors.positionOf(latestTarget) + if (!targetOffset.isNaN()) { + var prev = if (offset.isNaN()) 0f else offset + if (prev != targetOffset) { + // If targetOffset is not in the same direction as the direction of the drag (sign + // of the velocity) we fall back to using target animation. + // If the component is at the target offset already, we use decay animation that will + // not consume any velocity. + if (velocity * (targetOffset - prev) < 0f || velocity == 0f) { + animateTo(velocity, this, anchors, latestTarget) + remainingVelocity = 0f + } else { + val projectedDecayOffset = + decayAnimationSpec.calculateTargetValue(prev, velocity) + debugLog { + "offset = $prev\tvelocity = $velocity\t" + + "targetOffset = $targetOffset\tprojectedOffset = $projectedDecayOffset" + } + + val canDecayToTarget = if (velocity > 0) { + projectedDecayOffset >= targetOffset + } else { + projectedDecayOffset <= targetOffset + } + if (canDecayToTarget) { + debugLog { "Decay animation is used" } + AnimationState(prev, velocity) + .animateDecay(decayAnimationSpec) { + if (abs(value) >= abs(targetOffset)) { + val finalValue = value.coerceToTarget(targetOffset) + dragTo(finalValue, this.velocity) + remainingVelocity = + if (this.velocity.isNaN()) 0f else this.velocity + prev = finalValue + cancelAnimation() + } else { + dragTo(value, this.velocity) + remainingVelocity = this.velocity + prev = value + } + } + } else { + animateTo(velocity, this, anchors, latestTarget) + remainingVelocity = 0f + } + } + } + } + } + return velocity - remainingVelocity +} + +private fun Float.coerceToTarget(target: Float): Float { + if (target == 0f) return 0f + return if (target > 0) coerceAtMost(target) else coerceAtLeast(target) +} + +private class AnchoredDragFinishedSignal : CancellationException("Drag finished") + +private suspend fun restartable(inputs: () -> I, block: suspend (I) -> Unit) { + try { + coroutineScope { + var previousDrag: Job? = null + snapshotFlow(inputs) + .collect { latestInputs -> + previousDrag?.apply { + cancel(AnchoredDragFinishedSignal()) + join() + } + previousDrag = launch(start = CoroutineStart.UNDISPATCHED) { + block(latestInputs) + this@coroutineScope.cancel(AnchoredDragFinishedSignal()) + } + } + } + } catch (anchoredDragFinished: AnchoredDragFinishedSignal) { + // Ignored + } +} + +private fun emptyDraggableAnchors() = MapDraggableAnchors(MutableObjectFloatMap()) + +@OptIn(ExperimentalFoundationApi::class) +private class MapDraggableAnchors(private val anchors: ObjectFloatMap) : DraggableAnchors { + + override fun positionOf(value: T): Float = anchors.getOrDefault(value, Float.NaN) + + override fun hasAnchorFor(value: T) = anchors.containsKey(value) + + override fun closestAnchor(position: Float): T? { + var minAnchor: T? = null + var minDistance = Float.POSITIVE_INFINITY + anchors.forEach { anchor, anchorPosition -> + val distance = abs(position - anchorPosition) + if (distance <= minDistance) { + minAnchor = anchor + minDistance = distance + } + } + return minAnchor + } + + override fun closestAnchor( + position: Float, + searchUpwards: Boolean + ): T? { + var minAnchor: T? = null + var minDistance = Float.POSITIVE_INFINITY + anchors.forEach { anchor, anchorPosition -> + val delta = if (searchUpwards) anchorPosition - position else position - anchorPosition + val distance = if (delta < 0) Float.POSITIVE_INFINITY else delta + if (distance <= minDistance) { + minAnchor = anchor + minDistance = distance + } + } + return minAnchor + } + + override fun minAnchor() = anchors.minValueOrNaN() + + override fun maxAnchor() = anchors.maxValueOrNaN() + + override val size: Int + get() = anchors.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MapDraggableAnchors<*>) return false + + return anchors == other.anchors + } + + override fun hashCode() = 31 * anchors.hashCode() + + override fun toString() = "MapDraggableAnchors($anchors)" + + override fun forEach(block: (anchor: T, position: Float) -> Unit) { + anchors.forEach(block) + } +} + +private fun ObjectFloatMap.minValueOrNaN(): Float { + if (size == 1) return Float.NaN + var minValue = Float.POSITIVE_INFINITY + forEachValue { value -> + if (value <= minValue) { + minValue = value + } + } + return minValue +} + +private fun ObjectFloatMap.maxValueOrNaN(): Float { + if (size == 1) return Float.NaN + var maxValue = Float.NEGATIVE_INFINITY + forEachValue { value -> + if (value >= maxValue) { + maxValue = value + } + } + return maxValue +} + +private const val DEBUG = false +private inline fun debugLog(generateMsg: () -> String) { + if (DEBUG) { + println("AnchoredDraggable: ${generateMsg()}") + } +} diff --git a/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/CoreAnchoredDraggable.kt b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/CoreAnchoredDraggable.kt deleted file mode 100644 index 36fbc45..0000000 --- a/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/CoreAnchoredDraggable.kt +++ /dev/null @@ -1,749 +0,0 @@ -/* - * 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. - */ - -package androidx.compose.foundation.gestures - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.animate -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.MutatorMutex -import androidx.compose.foundation.CorePlatformOptimizedCancellationException -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.structuralEqualityPolicy -import androidx.compose.ui.Modifier -import kotlin.math.abs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch - -/** - * Structure that represents the anchors of a [CoreAnchoredDraggableState]. - * - * See the DraggableAnchors factory method to construct drag anchors using a default implementation. - */ -internal interface CoreDraggableAnchors { - - /** - * Get the anchor position for an associated [value] - * - * @param value The value to look up - * - * @return The position of the anchor, or [Float.NaN] if the anchor does not exist - */ - fun positionOf(value: T): Float - - /** - * Whether there is an anchor position associated with the [value] - * - * @param value The value to look up - * - * @return true if there is an anchor for this value, false if there is no anchor for this value - */ - fun hasAnchorFor(value: T): Boolean - - /** - * Find the closest anchor to the [position]. - * - * @param position The position to start searching from - * - * @return The closest anchor or null if the anchors are empty - */ - fun closestAnchor(position: Float): T? - - /** - * Find the closest anchor to the [position], in the specified direction. - * - * @param position The position to start searching from - * @param searchUpwards Whether to search upwards from the current position or downwards - * - * @return The closest anchor or null if the anchors are empty - */ - fun closestAnchor(position: Float, searchUpwards: Boolean): T? - - /** - * The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty. - */ - fun minAnchor(): Float - - /** - * The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty. - */ - fun maxAnchor(): Float - - /** - * The amount of anchors - */ - val size: Int -} - -/** - * [CoreDraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and - * corresponding [Float] positions. This [CoreDraggableAnchorsConfig] is used to construct an immutable - * [CoreDraggableAnchors] instance later on. - */ -internal class CoreDraggableAnchorsConfig { - - internal val anchors = mutableMapOf() - - /** - * Set the anchor position for [this] anchor. - * - * @param position The anchor position. - */ - @Suppress("BuilderSetStyle") - infix fun T.at(position: Float) { - anchors[this] = position - } -} - -/** - * Create a new [CoreDraggableAnchors] instance using a builder function. - * - * @param builder A function with a [CoreDraggableAnchorsConfig] that offers APIs to configure anchors - * @return A new [CoreDraggableAnchors] instance with the anchor positions set by the `builder` - * function. - */ -internal fun CoreDraggableAnchors( - builder: CoreDraggableAnchorsConfig.() -> Unit -): CoreDraggableAnchors = MapCoreDraggableAnchors(CoreDraggableAnchorsConfig().apply(builder).anchors) - -/** - * Enable drag gestures between a set of predefined values. - * - * When a drag is detected, the offset of the [CoreAnchoredDraggableState] will be updated with the drag - * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). - * When the drag ends, the offset will be animated to one of the anchors and when that anchor is - * reached, the value of the [CoreAnchoredDraggableState] will also be updated to the value - * corresponding to the new anchor. - * - * Dragging is constrained between the minimum and maximum anchors. - * - * @param state The associated [CoreAnchoredDraggableState]. - * @param orientation The orientation in which the [coreAnchoredDraggable] can be dragged. - * @param enabled Whether this [coreAnchoredDraggable] is enabled and should react to the user's input. - * @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom - * drag will behave like bottom to top, and a left to right drag will behave like right to left. - * @param interactionSource Optional [MutableInteractionSource] that will passed on to - * the internal [Modifier.draggable]. - * @param startDragImmediately when set to false, [draggable] will start dragging only when the - * gesture crosses the touchSlop. This is useful to prevent users from "catching" an animating - * widget when pressing on it. See [draggable] to learn more about startDragImmediately. - */ -internal fun Modifier.coreAnchoredDraggable( - state: CoreAnchoredDraggableState, - orientation: Orientation, - enabled: Boolean = true, - reverseDirection: Boolean = false, - interactionSource: MutableInteractionSource? = null, - startDragImmediately: Boolean = state.isAnimationRunning -) = draggable( - state = state.draggableState, - orientation = orientation, - enabled = enabled, - interactionSource = interactionSource, - reverseDirection = reverseDirection, - startDragImmediately = startDragImmediately, - onDragStopped = { velocity -> launch { state.settle(velocity) } } -) - -/** - * Scope used for suspending anchored drag blocks. Allows to set [CoreAnchoredDraggableState.offset] to - * a new value. - * - * @see [CoreAnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the - * access to this scope. - */ -internal interface CoreAnchoredDragScope { - /** - * Assign a new value for an offset value for [CoreAnchoredDraggableState]. - * - * @param newOffset new value for [CoreAnchoredDraggableState.offset]. - * @param lastKnownVelocity last known velocity (if known) - */ - fun dragTo( - newOffset: Float, - lastKnownVelocity: Float = 0f - ) -} - -/** - * State of the [coreAnchoredDraggable] modifier. - * Use the constructor overload with anchors if the anchors are defined in composition, or update - * the anchors using [updateAnchors]. - * - * This contains necessary information about any ongoing drag or animation and provides methods - * to change the state either immediately or by starting an animation. - * - * @param initialValue The initial value of the state. - * @param positionalThreshold The positional threshold, in px, to be used when calculating the - * target state while a drag is in progress and when settling after the drag 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. - * @param velocityThreshold The velocity threshold (in px 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. - * @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. - */ -@Stable -internal class CoreAnchoredDraggableState( - initialValue: T, - internal val positionalThreshold: (totalDistance: Float) -> Float, - internal val velocityThreshold: () -> Float, - val animationSpec: AnimationSpec, - internal val confirmValueChange: (newValue: T) -> Boolean = { true } -) { - - /** - * Construct an [CoreAnchoredDraggableState] instance with anchors. - * - * @param initialValue The initial value of the state. - * @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later. - * @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, in px, to be used when calculating the - * target state while a drag is in progress and when settling after the drag 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. - * @param velocityThreshold The velocity threshold (in px 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. - */ - internal constructor( - initialValue: T, - anchors: CoreDraggableAnchors, - positionalThreshold: (totalDistance: Float) -> Float, - velocityThreshold: () -> Float, - animationSpec: AnimationSpec, - confirmValueChange: (newValue: T) -> Boolean = { true } - ) : this( - initialValue, - positionalThreshold, - velocityThreshold, - animationSpec, - confirmValueChange - ) { - this.anchors = anchors - trySnapTo(initialValue) - } - - private val dragMutex = MutatorMutex() - - internal val draggableState = object : DraggableState { - - private val dragScope = object : DragScope { - override fun dragBy(pixels: Float) { - with(coreAnchoredDragScope) { - dragTo(newOffsetForDelta(pixels)) - } - } - } - - override suspend fun drag( - dragPriority: MutatePriority, - block: suspend DragScope.() -> Unit - ) { - this@CoreAnchoredDraggableState.anchoredDrag(dragPriority) { - with(dragScope) { block() } - } - } - - override fun dispatchRawDelta(delta: Float) { - this@CoreAnchoredDraggableState.dispatchRawDelta(delta) - } - } - - /** - * The current value of the [CoreAnchoredDraggableState]. - */ - 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 { - dragTarget ?: run { - val currentOffset = offset - if (!currentOffset.isNaN()) { - computeTarget(currentOffset, currentValue, velocity = 0f) - } else currentValue - } - } - - /** - * The closest value in the swipe direction from the current offset, not considering thresholds. - * If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if - * specified). - */ - internal val closestValue: T by derivedStateOf { - dragTarget ?: run { - val currentOffset = offset - if (!currentOffset.isNaN()) { - computeTargetWithoutThresholds(currentOffset, currentValue) - } else currentValue - } - } - - /** - * The current offset, or [Float.NaN] if it has not been initialized yet. - * - * The offset will be initialized when the anchors are first set through [updateAnchors]. - * - * Strongly consider using [requireOffset] which will throw if the offset is read before it is - * initialized. This helps catch issues early in your workflow. - */ - var offset: Float by mutableFloatStateOf(Float.NaN) - private set - - /** - * Require the current offset. - * - * @see offset - * - * @throws IllegalStateException If the offset has not been initialized yet - */ - fun requireOffset(): Float { - check(!offset.isNaN()) { - "The offset was read before being initialized. Did you access the offset in a phase " + - "before layout, like effects or composition?" - } - return offset - } - - /** - * Whether an animation is currently in progress. - */ - val isAnimationRunning: Boolean get() = dragTarget != null - - /** - * The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f] - * bounds, or 1f if the [CoreAnchoredDraggableState] is in a settled state. - */ -// @get:FloatRange(from = 0.0, to = 1.0) - val progress: Float by derivedStateOf(structuralEqualityPolicy()) { - val a = anchors.positionOf(currentValue) - val b = anchors.positionOf(closestValue) - val distance = abs(b - a) - if (!distance.isNaN() && 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 mutableFloatStateOf(0f) - private set - - private var dragTarget: T? by mutableStateOf(null) - - var anchors: CoreDraggableAnchors by mutableStateOf(emptyDraggableAnchors()) - private set - - /** - * Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget], - * otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new - * anchors. - * - * If your anchors depend on the size of the layout, updateAnchors should be called in the - * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the - * state is set up within the same frame. - * For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to - * be called from side effects or layout. - * - * @param newAnchors The new anchors. - * @param newTarget The new target, by default the closest anchor or the current target if there - * are no anchors. - */ - fun updateAnchors( - newAnchors: CoreDraggableAnchors, - newTarget: T = if (!offset.isNaN()) { - newAnchors.closestAnchor(offset) ?: targetValue - } else targetValue - ) { - if (anchors != newAnchors) { - anchors = newAnchors - // Attempt to snap. If nobody is holding the lock, we can immediately update the offset. - // If anybody is holding the lock, we send a signal to restart the ongoing work with the - // updated anchors. - val snapSuccessful = trySnapTo(newTarget) - if (!snapSuccessful) { - dragTarget = newTarget - } - } - } - - /** - * Find the closest anchor, taking into account the [velocityThreshold] and - * [positionalThreshold], and settle at it with an animation. - * - * If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and - * [positionalThreshold] will be the target. If the [velocity] is higher than the - * [velocityThreshold], the [positionalThreshold] will not be considered and the next - * anchor in the direction indicated by the sign of the [velocity] will be the target. - */ - 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) - } - } - - private fun computeTarget( - offset: Float, - currentValue: T, - velocity: Float - ): T { - val currentAnchors = anchors - val currentAnchorPosition = currentAnchors.positionOf(currentValue) - val velocityThresholdPx = velocityThreshold() - return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) { - currentValue - } else { - if (abs(velocity) >= abs(velocityThresholdPx)) { - currentAnchors.closestAnchor( - offset, - offset - currentAnchorPosition > 0 - )!! - } else { - val neighborAnchor = - currentAnchors.closestAnchor( - offset, - offset - currentAnchorPosition > 0 - )!! - val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor) - val distance = abs(currentAnchorPosition - neighborAnchorPosition) - val relativeThreshold = abs(positionalThreshold(distance)) - val relativePosition = abs(currentAnchorPosition - offset) - if (relativePosition <= relativeThreshold) currentValue else neighborAnchor - } - } - } - - private fun computeTargetWithoutThresholds( - offset: Float, - currentValue: T, - ): T { - val currentAnchors = anchors - val currentAnchor = currentAnchors.positionOf(currentValue) - return if (currentAnchor == offset || currentAnchor.isNaN()) { - currentValue - } else { - currentAnchors.closestAnchor( - offset, - offset - currentAnchor > 0 - ) ?: currentValue - } - } - - private val coreAnchoredDragScope: CoreAnchoredDragScope = object : CoreAnchoredDragScope { - override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { - offset = newOffset - lastVelocity = lastKnownVelocity - } - } - - /** - * Call this function to take control of drag logic and perform anchored drag with the latest - * anchors. - * - * All actions that change the [offset] of this [CoreAnchoredDraggableState] must be performed - * within an [anchoredDrag] block (even if they don't call any other methods on this object) - * in order to guarantee that mutual exclusion is enforced. - * - * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing - * drag, the ongoing drag will be cancelled. - * - * If the [anchors] change while the [block] is being executed, it will be cancelled and - * re-executed with the latest anchors and target. This allows you to target the correct - * state. - * - * @param dragPriority of the drag operation - * @param block perform anchored drag given the current anchor provided - */ - suspend fun anchoredDrag( - dragPriority: MutatePriority = MutatePriority.Default, - block: suspend CoreAnchoredDragScope.(anchors: CoreDraggableAnchors) -> Unit - ) { - try { - dragMutex.mutate(dragPriority) { - restartable(inputs = { anchors }) { latestAnchors -> - coreAnchoredDragScope.block(latestAnchors) - } - } - } finally { - val closest = anchors.closestAnchor(offset) - if (closest != null && - abs(offset - anchors.positionOf(closest)) <= 0.5f && - confirmValueChange.invoke(closest) - ) { - currentValue = closest - } - } - } - - /** - * Call this function to take control of drag logic and perform anchored drag with the latest - * anchors and target. - * - * All actions that change the [offset] of this [CoreAnchoredDraggableState] must be performed - * within an [anchoredDrag] block (even if they don't call any other methods on this object) - * in order to guarantee that mutual exclusion is enforced. - * - * This overload allows the caller to hint the target value that this [anchoredDrag] is intended - * to arrive to. This will set [CoreAnchoredDraggableState.targetValue] to provided value so - * consumers can reflect it in their UIs. - * - * If the [anchors] or [CoreAnchoredDraggableState.targetValue] change while the [block] is being - * executed, it will be cancelled and re-executed with the latest anchors and target. This - * allows you to target the correct state. - * - * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing - * drag, the ongoing drag will be cancelled. - * - * @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to - * @param dragPriority of the drag operation - * @param block perform anchored drag given the current anchor provided - */ - suspend fun anchoredDrag( - targetValue: T, - dragPriority: MutatePriority = MutatePriority.Default, - block: suspend CoreAnchoredDragScope.(anchors: CoreDraggableAnchors, targetValue: T) -> Unit - ) { - if (anchors.hasAnchorFor(targetValue)) { - try { - dragMutex.mutate(dragPriority) { - dragTarget = targetValue - restartable( - inputs = { anchors to this@CoreAnchoredDraggableState.targetValue } - ) { (latestAnchors, latestTarget) -> - coreAnchoredDragScope.block(latestAnchors, latestTarget) - } - } - } finally { - dragTarget = null - val closest = anchors.closestAnchor(offset) - if (closest != null && - abs(offset - anchors.positionOf(closest)) <= 0.5f && - confirmValueChange.invoke(closest) - ) { - currentValue = closest - } - } - } else { - // Todo: b/283467401, revisit this behavior - currentValue = targetValue - } - } - - internal fun newOffsetForDelta(delta: Float) = - ((if (offset.isNaN()) 0f else offset) + delta) - .coerceIn(anchors.minAnchor(), anchors.maxAnchor()) - - /** - * Drag by the [delta], coerce it in the bounds and dispatch it to the [CoreAnchoredDraggableState]. - * - * @return The delta the consumed by the [CoreAnchoredDraggableState] - */ - fun dispatchRawDelta(delta: Float): Float { - val newOffset = newOffsetForDelta(delta) - val oldOffset = if (offset.isNaN()) 0f else offset - offset = newOffset - return newOffset - oldOffset - } - - /** - * Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag - * transaction like a drag or an animation is progress. If there is another interaction in - * progress, the suspending [snapTo] overload needs to be used. - * - * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous - */ - private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate { - with(coreAnchoredDragScope) { - val targetOffset = anchors.positionOf(targetValue) - if (!targetOffset.isNaN()) { - dragTo(targetOffset) - dragTarget = null - } - currentValue = targetValue - } - } - - companion object { - /** - * The default [Saver] implementation for [CoreAnchoredDraggableState]. - */ - internal fun Saver( - animationSpec: AnimationSpec, - positionalThreshold: (distance: Float) -> Float, - velocityThreshold: () -> Float, - confirmValueChange: (T) -> Boolean = { true }, - ) = Saver, T>( - save = { it.currentValue }, - restore = { - CoreAnchoredDraggableState( - initialValue = it, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = velocityThreshold - ) - } - ) - } -} - -/** - * Snap to a [targetValue] without any animation. - * If the [targetValue] is not in the set of anchors, the [CoreAnchoredDraggableState.currentValue] will - * be updated to the [targetValue] without updating the offset. - * - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - * - * @param targetValue The target value of the animation - */ -internal suspend fun CoreAnchoredDraggableState.snapTo(targetValue: T) { - anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> - val targetOffset = anchors.positionOf(latestTarget) - if (!targetOffset.isNaN()) dragTo(targetOffset) - } -} - -/** - * Animate to a [targetValue]. - * If the [targetValue] is not in the set of anchors, the [CoreAnchoredDraggableState.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 - */ -internal suspend fun CoreAnchoredDraggableState.animateTo( - targetValue: T, - velocity: Float = this.lastVelocity, -) { - anchoredDrag(targetValue = targetValue) { anchors, latestTarget -> - val targetOffset = anchors.positionOf(latestTarget) - if (!targetOffset.isNaN()) { - var prev = if (offset.isNaN()) 0f else offset - 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. - dragTo(value, velocity) - prev = value - } - } - } -} - -private class CoreAnchoredDragFinishedSignal : CorePlatformOptimizedCancellationException() - -private suspend fun restartable(inputs: () -> I, block: suspend (I) -> Unit) { - try { - coroutineScope { - var previousDrag: Job? = null - snapshotFlow(inputs) - .collect { latestInputs -> - previousDrag?.apply { - cancel(CoreAnchoredDragFinishedSignal()) - join() - } - previousDrag = launch(start = CoroutineStart.UNDISPATCHED) { - block(latestInputs) - this@coroutineScope.cancel(CoreAnchoredDragFinishedSignal()) - } - } - } - } catch (anchoredDragFinished: CoreAnchoredDragFinishedSignal) { - // Ignored - } -} - -private fun emptyDraggableAnchors() = MapCoreDraggableAnchors(emptyMap()) - -private class MapCoreDraggableAnchors(private val anchors: Map) : CoreDraggableAnchors { - - override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN - override fun hasAnchorFor(value: T) = anchors.containsKey(value) - - override fun closestAnchor(position: Float): T? = anchors.minByOrNull { - abs(position - it.value) - }?.key - - override fun closestAnchor( - position: Float, - searchUpwards: Boolean - ): T? { - return anchors.minByOrNull { (_, anchor) -> - val delta = if (searchUpwards) anchor - position else position - anchor - if (delta < 0) Float.POSITIVE_INFINITY else delta - }?.key - } - - override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN - - override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN - - override val size: Int - get() = anchors.size - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is MapCoreDraggableAnchors<*>) return false - - return anchors == other.anchors - } - - override fun hashCode() = 31 * anchors.hashCode() - - override fun toString() = "MapDraggableAnchors($anchors)" -} diff --git a/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt new file mode 100644 index 0000000..43413f6 --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt @@ -0,0 +1,957 @@ +// File copied from Compose Foundation 1.7.0 +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.gestures + +// Note, that there is a copy-paste version of this file (DragGestureDetectorCopy.kt), don't +// forget to change it too. +// +// We can't make *PointerSlop* functions public just yet because the new pointer API isn't ready. + +// TODO(b/193549931): when the new pointer API will be ready we should make *PointerSlop* +// functions public + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +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.PointerType +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed +import androidx.compose.ui.input.pointer.positionChangedIgnoreConsumed +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastForEach +import kotlin.math.absoluteValue +import kotlin.math.sign +import kotlinx.coroutines.CancellationException + +/** + * Waits for drag motion to pass [touch slop][ViewConfiguration.touchSlop], using [pointerId] as + * the pointer to examine. If [pointerId] is raised, another pointer from those that are down + * will be chosen to lead the gesture, and if none are down, `null` is returned. If [pointerId] + * is not down when [awaitTouchSlopOrCancellation] is called, then `null` is returned. + + * [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the any direction + * with the change that caused the motion beyond touch slop and the [Offset] beyond touch slop that + * has passed. [onTouchSlopReached] should consume the position change if it accepts the motion. + * If it does, then the method returns that [PointerInputChange]. If not, touch slop detection will + * continue. + * + * @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all + * pointers are raised before touch slop is detected or another gesture consumed the position + * change. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitDragOrCancellationSample + * + * @see awaitHorizontalTouchSlopOrCancellation + * @see awaitVerticalTouchSlopOrCancellation + */ +suspend fun AwaitPointerEventScope.awaitTouchSlopOrCancellation( + pointerId: PointerId, + onTouchSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit +): PointerInputChange? { + return awaitPointerSlopOrCancellation( + pointerId, + PointerType.Touch, + onPointerSlopReached = onTouchSlopReached, + orientation = null, + ) +} + +/** + * Reads position change events for [pointerId] and calls [onDrag] for every change in + * position. If [pointerId] is raised, a new pointer is chosen from those that are down and if + * none exist, the method returns. This does not wait for touch slop. + * + * @return `true` if the drag completed normally or `false` if the drag motion was + * canceled by another gesture detector consuming position change events. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.DragSample + * + * @see awaitTouchSlopOrCancellation + * @see awaitDragOrCancellation + * @see horizontalDrag + * @see verticalDrag + */ +suspend fun AwaitPointerEventScope.drag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit +): Boolean { + var pointer = pointerId + while (true) { + val change = awaitDragOrCancellation(pointer) ?: return false + + if (change.changedToUpIgnoreConsumed()) { + return true + } + + onDrag(change) + pointer = change.id + } +} + +/** + * Reads pointer input events until a drag is detected or all pointers are up. When the final + * pointer is raised, the up event is returned. When a drag event is detected, the + * drag change will be returned. Note that if [pointerId] has been raised, another pointer + * that is down will be used, if available, so the returned [PointerInputChange.id] may + * differ from [pointerId]. If the position change in the any direction has been + * consumed by the [PointerEventPass.Main] pass, then the drag is considered canceled and `null` + * is returned. If [pointerId] is not down when [awaitDragOrCancellation] is called, then + * `null` is returned. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitDragOrCancellationSample + * + * @see awaitVerticalDragOrCancellation + * @see awaitHorizontalDragOrCancellation + * @see drag + */ +suspend fun AwaitPointerEventScope.awaitDragOrCancellation( + pointerId: PointerId, +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the gesture is canceled + } + val change = awaitDragOrUp(pointerId) { it.positionChangedIgnoreConsumed() } + return if (change?.isConsumed == false) change else null +} + +/** + * Gesture detector that waits for pointer down and touch slop in any direction and then + * calls [onDrag] for each drag event. It follows the touch slop detection of + * [awaitTouchSlopOrCancellation] but will consume the position change automatically + * once the touch slop has been crossed. + * + * [onDragStart] called when the touch slop has been passed and includes an [Offset] representing + * the last known pointer position relative to the containing element. The [Offset] can be outside + * the actual bounds of the element itself meaning the numbers can be negative or larger than the + * element bounds if the touch target is smaller than the + * [ViewConfiguration.minimumTouchTargetSize]. + * + * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture + * has consumed pointer input, canceling this gesture. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.DetectDragGesturesSample + * + * @see detectVerticalDragGestures + * @see detectHorizontalDragGestures + * @see detectDragGesturesAfterLongPress to detect gestures after long press + */ +suspend fun PointerInputScope.detectDragGestures( + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) = detectDragGestures( + onDragStart = { change, _ -> onDragStart(change.position) }, + onDragEnd = { onDragEnd.invoke() }, + onDragCancel = onDragCancel, + shouldAwaitTouchSlop = { true }, + orientationLock = null, + onDrag = onDrag +) + +/** + * A Gesture detector that waits for pointer down and touch slop in the direction specified by + * [orientationLock] and then calls [onDrag] for each drag event. + * It follows the touch slop detection of [awaitTouchSlopOrCancellation] but will consume the + * position change automatically once the touch slop has been crossed, the amount of drag over + * the touch slop is reported as the first drag event [onDrag] after the slop is crossed. + * If [shouldAwaitTouchSlop] returns true the touch slop recognition phase will be ignored + * and the drag gesture will be recognized immediately.The first [onDrag] in this case will report + * an [Offset.Zero]. + * + * [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing + * the last known pointer position relative to the containing element as well as the initial + * down event that triggered this gesture detection cycle. The [Offset] can be outside + * the actual bounds of the element itself meaning the numbers can be negative or larger than the + * element bounds if the touch target is smaller than the + * [ViewConfiguration.minimumTouchTargetSize]. + * + * [onDragEnd] is called after all pointers are up with the event change of the up event + * and [onDragCancel] is called if another gesture has consumed pointer input, + * canceling this gesture. + * + * @param onDragStart A lambda to be called when the drag gesture starts, it contains information + * about the last known [PointerInputChange] relative to the containing element and the post slop + * delta. + * @param onDragEnd A lambda to be called when the gesture ends. It contains information about the + * up [PointerInputChange] that finished the gesture. + * @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or + * when it was consumed. + * @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped. + * @param orientationLock Optionally locks detection to this orientation, this means, when this is + * provided, touch slop detection and drag event detection will be conditioned to the given + * orientation axis. [onDrag] will still dispatch events on with information in both axis, but + * if orientation lock is provided, only events that happen on the given orientation will be + * considered. If no value is provided (i.e. null) touch slop and drag detection will happen on + * an "any" orientation basis, that is, touch slop will be detected if crossed in either direction + * and drag events will be dispatched if present in either direction. + * @param onDrag A lambda to be called for each delta event in the gesture. It contains information + * about the [PointerInputChange] and the movement offset. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.DetectDragGesturesSample + * + * @see detectVerticalDragGestures + * @see detectHorizontalDragGestures + * @see detectDragGesturesAfterLongPress to detect gestures after long press + */ +internal suspend fun PointerInputScope.detectDragGestures( + onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit, + onDragEnd: (change: PointerInputChange) -> Unit, + onDragCancel: () -> Unit, + shouldAwaitTouchSlop: () -> Boolean, + orientationLock: Orientation?, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) { + awaitEachGesture { + val initialDown = + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val awaitTouchSlop = shouldAwaitTouchSlop() + + if (!awaitTouchSlop) { + initialDown.consume() + } + val down = awaitFirstDown(requireUnconsumed = false) + var drag: PointerInputChange? + var overSlop = Offset.Zero + var initialDelta = Offset.Zero + + if (awaitTouchSlop) { + do { + drag = awaitPointerSlopOrCancellation( + down.id, + down.type, + orientation = orientationLock + ) { change, over -> + change.consume() + overSlop = over + } + } while (drag != null && !drag.isConsumed) + initialDelta = overSlop + } else { + drag = initialDown + } + + if (drag != null) { + onDragStart.invoke(drag, initialDelta) + onDrag(drag, overSlop) + val upEvent = drag( + pointerId = drag.id, + onDrag = { + onDrag(it, it.positionChange()) + it.consume() + }, + orientation = orientationLock, + motionConsumed = { + it.isConsumed + }) + if (upEvent == null) { + onDragCancel() + } else { + onDragEnd(upEvent) + } + } + } +} + +/** + * Gesture detector that waits for pointer down and long press, after which it calls [onDrag] for + * each drag event. + * + * [onDragStart] called when a long press is detected and includes an [Offset] representing + * the last known pointer position relative to the containing element. The [Offset] can be outside + * the actual bounds of the element itself meaning the numbers can be negative or larger than the + * element bounds if the touch target is smaller than the + * [ViewConfiguration.minimumTouchTargetSize]. + * + * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture + * has consumed pointer input, canceling this gesture. This function will automatically consume all + * the position change after the long press. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.DetectDragWithLongPressGesturesSample + * + * @see detectVerticalDragGestures + * @see detectHorizontalDragGestures + * @see detectDragGestures + */ +suspend fun PointerInputScope.detectDragGesturesAfterLongPress( + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) { + awaitEachGesture { + try { + val down = awaitFirstDown(requireUnconsumed = false) + val drag = awaitLongPressOrCancellation(down.id) + if (drag != null) { + onDragStart.invoke(drag.position) + + if ( + drag(drag.id) { + onDrag(it, it.positionChange()) + it.consume() + } + ) { + // consume up if we quit drag gracefully with the up + currentEvent.changes.fastForEach { + if (it.changedToUp()) it.consume() + } + onDragEnd() + } else { + onDragCancel() + } + } + } catch (c: CancellationException) { + onDragCancel() + throw c + } + } +} + +/** + * Waits for vertical drag motion to pass [touch slop][ViewConfiguration.touchSlop], using + * [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from + * those that are down will be chosen to lead the gesture, and if none are down, `null` is returned. + * If [pointerId] is not down when [awaitVerticalTouchSlopOrCancellation] is called, then `null` + * is returned. + * + * [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the vertical + * direction with the change that caused the motion beyond touch slop and the pixels beyond touch + * slop. [onTouchSlopReached] should consume the position change if it accepts the motion. + * If it does, then the method returns that [PointerInputChange]. If not, touch slop detection will + * continue. + * + * @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all + * pointers are raised before touch slop is detected or another gesture consumed the position + * change. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitVerticalDragOrCancellationSample + * + * @see awaitHorizontalTouchSlopOrCancellation + * @see awaitTouchSlopOrCancellation + */ +suspend fun AwaitPointerEventScope.awaitVerticalTouchSlopOrCancellation( + pointerId: PointerId, + onTouchSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit +) = awaitPointerSlopOrCancellation( + pointerId = pointerId, + pointerType = PointerType.Touch, + onPointerSlopReached = { change, overSlop -> onTouchSlopReached(change, overSlop.y) }, + orientation = Orientation.Vertical +) + +internal suspend fun AwaitPointerEventScope.awaitVerticalPointerSlopOrCancellation( + pointerId: PointerId, + pointerType: PointerType, + onTouchSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit +) = awaitPointerSlopOrCancellation( + pointerId = pointerId, + pointerType = pointerType, + onPointerSlopReached = { change, overSlop -> onTouchSlopReached(change, overSlop.y) }, + orientation = Orientation.Vertical +) + +/** + * Reads vertical position change events for [pointerId] and calls [onDrag] for every change in + * position. If [pointerId] is raised, a new pointer is chosen from those that are down and if + * none exist, the method returns. This does not wait for touch slop + * + * @return `true` if the vertical drag completed normally or `false` if the drag motion was + * canceled by another gesture detector consuming position change events. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.VerticalDragSample + * + * @see awaitVerticalTouchSlopOrCancellation + * @see awaitVerticalDragOrCancellation + * @see horizontalDrag + * @see drag + */ +suspend fun AwaitPointerEventScope.verticalDrag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit +): Boolean = drag( + pointerId = pointerId, + onDrag = onDrag, + orientation = Orientation.Vertical, + motionConsumed = { it.isConsumed } +) != null + +/** + * Reads pointer input events until a vertical drag is detected or all pointers are up. When the + * final pointer is raised, the up event is returned. When a drag event is detected, the + * drag change will be returned. Note that if [pointerId] has been raised, another pointer + * that is down will be used, if available, so the returned [PointerInputChange.id] may + * differ from [pointerId]. If the position change has been consumed by the + * [PointerEventPass.Main] pass, then the drag is considered canceled and `null` is returned. If + * [pointerId] is not down when [awaitVerticalDragOrCancellation] is called, then `null` is + * returned. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitVerticalDragOrCancellationSample + * + * @see awaitHorizontalDragOrCancellation + * @see awaitDragOrCancellation + * @see verticalDrag + */ +suspend fun AwaitPointerEventScope.awaitVerticalDragOrCancellation( + pointerId: PointerId, +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the gesture is canceled + } + val change = awaitDragOrUp(pointerId) { it.positionChangeIgnoreConsumed().y != 0f } + return if (change?.isConsumed == false) change else null +} + +/** + * Gesture detector that waits for pointer down and touch slop in the vertical direction and then + * calls [onVerticalDrag] for each vertical drag event. It follows the touch slop detection of + * [awaitVerticalTouchSlopOrCancellation], but will consume the position change automatically + * once the touch slop has been crossed. + * + * [onDragStart] called when the touch slop has been passed and includes an [Offset] representing + * the last known pointer position relative to the containing element. The [Offset] can be outside + * the actual bounds of the element itself meaning the numbers can be negative or larger than the + * element bounds if the touch target is smaller than the + * [ViewConfiguration.minimumTouchTargetSize]. + * + * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture + * has consumed pointer input, canceling this gesture. + * + * This gesture detector will coordinate with [detectHorizontalDragGestures] and + * [awaitHorizontalTouchSlopOrCancellation] to ensure only vertical or horizontal dragging + * is locked, but not both. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.DetectVerticalDragGesturesSample + * + * @see detectDragGestures + * @see detectHorizontalDragGestures + */ +suspend fun PointerInputScope.detectVerticalDragGestures( + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit +) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var overSlop = 0f + val drag = awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over -> + change.consume() + overSlop = over + } + if (drag != null) { + onDragStart.invoke(drag.position) + onVerticalDrag.invoke(drag, overSlop) + if ( + verticalDrag(drag.id) { + onVerticalDrag(it, it.positionChange().y) + it.consume() + } + ) { + onDragEnd() + } else { + onDragCancel() + } + } + } +} + +/** + * Waits for horizontal drag motion to pass [touch slop][ViewConfiguration.touchSlop], using + * [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from + * those that are down will be chosen to lead the gesture, and if none are down, `null` is returned. + + * [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the horizontal + * direction with the change that caused the motion beyond touch slop and the pixels beyond touch + * slop. [onTouchSlopReached] should consume the position change if it accepts the motion. + * If it does, then the method returns that [PointerInputChange]. If not, touch slop detection will + * continue. If [pointerId] is not down when [awaitHorizontalTouchSlopOrCancellation] is called, + * then `null` is returned. + * + * @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all + * pointers are raised before touch slop is detected or another gesture consumed the position + * change. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitHorizontalDragOrCancellationSample + * + * @see awaitVerticalTouchSlopOrCancellation + * @see awaitTouchSlopOrCancellation + */ +suspend fun AwaitPointerEventScope.awaitHorizontalTouchSlopOrCancellation( + pointerId: PointerId, + onTouchSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit +) = awaitPointerSlopOrCancellation( + pointerId = pointerId, + pointerType = PointerType.Touch, + onPointerSlopReached = { change, overSlop -> onTouchSlopReached(change, overSlop.x) }, + orientation = Orientation.Horizontal +) + +internal suspend fun AwaitPointerEventScope.awaitHorizontalPointerSlopOrCancellation( + pointerId: PointerId, + pointerType: PointerType, + onPointerSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit +) = awaitPointerSlopOrCancellation( + pointerId = pointerId, + pointerType = pointerType, + onPointerSlopReached = { change, overSlop -> onPointerSlopReached(change, overSlop.x) }, + orientation = Orientation.Horizontal +) + +/** + * Reads horizontal position change events for [pointerId] and calls [onDrag] for every change in + * position. If [pointerId] is raised, a new pointer is chosen from those that are down and if + * none exist, the method returns. This does not wait for touch slop. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.HorizontalDragSample + * + * @see awaitHorizontalTouchSlopOrCancellation + * @see awaitDragOrCancellation + * @see verticalDrag + * @see drag + */ +suspend fun AwaitPointerEventScope.horizontalDrag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit +): Boolean = drag( + pointerId = pointerId, + onDrag = onDrag, + orientation = Orientation.Horizontal, + motionConsumed = { it.isConsumed } +) != null + +/** + * Reads pointer input events until a horizontal drag is detected or all pointers are up. When the + * final pointer is raised, the up event is returned. When a drag event is detected, the + * drag change will be returned. Note that if [pointerId] has been raised, another pointer + * that is down will be used, if available, so the returned [PointerInputChange.id] may + * differ from [pointerId]. If the position change has been consumed by the + * [PointerEventPass.Main] pass, then the drag is considered canceled and `null` is returned. If + * [pointerId] is not down when [awaitHorizontalDragOrCancellation] is called, then `null` is + * returned. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitHorizontalDragOrCancellationSample + * + * @see horizontalDrag + * @see awaitVerticalDragOrCancellation + * @see awaitDragOrCancellation + */ +suspend fun AwaitPointerEventScope.awaitHorizontalDragOrCancellation( + pointerId: PointerId, +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the gesture is canceled + } + val change = awaitDragOrUp(pointerId) { it.positionChangeIgnoreConsumed().x != 0f } + return if (change?.isConsumed == false) change else null +} + +/** + * Gesture detector that waits for pointer down and touch slop in the horizontal direction and + * then calls [onHorizontalDrag] for each horizontal drag event. It follows the touch slop + * detection of [awaitHorizontalTouchSlopOrCancellation], but will consume the position change + * automatically once the touch slop has been crossed. + * + * [onDragStart] called when the touch slop has been passed and includes an [Offset] representing + * the last known pointer position relative to the containing element. The [Offset] can be outside + * the actual bounds of the element itself meaning the numbers can be negative or larger than the + * element bounds if the touch target is smaller than the + * [ViewConfiguration.minimumTouchTargetSize]. + * + * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture + * has consumed pointer input, canceling this gesture. + * + * This gesture detector will coordinate with [detectVerticalDragGestures] and + * [awaitVerticalTouchSlopOrCancellation] to ensure only vertical or horizontal dragging is locked, + * but not both. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.DetectHorizontalDragGesturesSample + * + * @see detectVerticalDragGestures + * @see detectDragGestures + */ +suspend fun PointerInputScope.detectHorizontalDragGestures( + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onHorizontalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit +) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var overSlop = 0f + val drag = awaitHorizontalPointerSlopOrCancellation( + down.id, + down.type + ) { change, over -> + change.consume() + overSlop = over + } + if (drag != null) { + onDragStart.invoke(drag.position) + onHorizontalDrag(drag, overSlop) + if ( + horizontalDrag(drag.id) { + onHorizontalDrag(it, it.positionChange().x) + it.consume() + } + ) { + onDragEnd() + } else { + onDragCancel() + } + } + } +} + +/** + * Continues to read drag events until all pointers are up or the drag event is canceled. + * The initial pointer to use for driving the drag is [pointerId]. [onDrag] is called + * whenever the pointer moves. The up event is returned at the end of the drag gesture. + * + * @param pointerId The pointer where that is driving the gesture. + * @param onDrag Callback for every new drag event. + * @param motionConsumed If the PointerInputChange should be considered as consumed. + * + * @return The last pointer input event change when gesture ended with all pointers up + * and null when the gesture was canceled. + */ +internal suspend inline fun AwaitPointerEventScope.drag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit, + orientation: Orientation?, + motionConsumed: (PointerInputChange) -> Boolean +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the gesture is canceled + } + var pointer = pointerId + while (true) { + val change = awaitDragOrUp(pointer) { + val positionChange = it.positionChangeIgnoreConsumed() + val motionChange = if (orientation == null) { + positionChange.getDistance() + } else { + if (orientation == Orientation.Vertical) positionChange.y else positionChange.x + } + motionChange != 0.0f + } ?: return null + + if (motionConsumed(change)) { + return null + } + + if (change.changedToUpIgnoreConsumed()) { + return change + } + + onDrag(change) + pointer = change.id + } +} + +/** + * Waits for a single drag in one axis, final pointer up, or all pointers are up. + * When [pointerId] has lifted, another pointer that is down is chosen to be the finger + * governing the drag. When the final pointer is lifted, that [PointerInputChange] is + * returned. When a drag is detected, that [PointerInputChange] is returned. A drag is + * only detected when [hasDragged] returns `true`. + * + * `null` is returned if there was an error in the pointer input stream and the pointer + * that was down was dropped before the 'up' was received. + */ +private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( + pointerId: PointerId, + hasDragged: (PointerInputChange) -> Boolean +): 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) { + // This is the last "up" + return dragEvent + } else { + pointer = otherDown.id + } + } else if (hasDragged(dragEvent)) { + return dragEvent + } + } +} + +/** + * Waits for drag motion and uses [orientation] to detect the direction of touch slop detection. + * It passes [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from + * those that are down will be chosen to + * lead the gesture, and if none are down, `null` is returned. If [pointerId] is not down when + * [awaitPointerSlopOrCancellation] is called, then `null` is returned. + * + * When pointer slop is detected, [onPointerSlopReached] is called with the change and the distance + * beyond the pointer slop. If [onPointerSlopReached] does not consume the + * position change, pointer slop will not have been considered detected and the detection will + * continue or, if it is consumed, the [PointerInputChange] that was consumed will be returned. + * + * This works with [awaitTouchSlopOrCancellation] for the other axis to ensure that only horizontal + * or vertical dragging is done, but not both. It also works for dragging in two ways when using + * [awaitTouchSlopOrCancellation] + * + * @return The [PointerInputChange] of the event that was consumed in [onPointerSlopReached] or + * `null` if all pointers are raised or the position change was consumed by another gesture + * detector. + */ +private suspend inline fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( + pointerId: PointerId, + pointerType: PointerType, + orientation: Orientation?, + onPointerSlopReached: (PointerInputChange, Offset) -> Unit, +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the gesture is canceled + } + val touchSlop = viewConfiguration.pointerSlop(pointerType) + var pointer: PointerId = pointerId + val touchSlopDetector = TouchSlopDetector(orientation) + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null + if (dragEvent.isConsumed) { + return null + } else if (dragEvent.changedToUpIgnoreConsumed()) { + val otherDown = event.changes.fastFirstOrNull { it.pressed } + if (otherDown == null) { + // This is the last "up" + return null + } else { + pointer = otherDown.id + } + } else { + val postSlopOffset = touchSlopDetector.addPointerInputChange(dragEvent, touchSlop) + if (postSlopOffset != null) { + onPointerSlopReached( + dragEvent, + postSlopOffset + ) + if (dragEvent.isConsumed) { + return dragEvent + } else { + touchSlopDetector.reset() + } + } else { + // verify that nothing else consumed the drag event + awaitPointerEvent(PointerEventPass.Final) + if (dragEvent.isConsumed) { + return null + } + } + } + } +} + +/** + * Detects if touch slop has been crossed after adding a series of [PointerInputChange]. + * For every new [PointerInputChange] one should add it to this detector using + * [addPointerInputChange]. If the position change causes the touch slop to be crossed, + * [addPointerInputChange] will return true. + */ +private class TouchSlopDetector(val orientation: Orientation? = null) { + + fun Offset.mainAxis() = if (orientation == Orientation.Horizontal) x else y + fun Offset.crossAxis() = if (orientation == Orientation.Horizontal) y else x + + /** + * The accumulation of drag deltas in this detector. + */ + private var totalPositionChange: Offset = Offset.Zero + + /** + * Adds [dragEvent] to this detector. If the accumulated position changes crosses the touch + * slop provided by [touchSlop], this method will return the post slop offset, that is the + * total accumulated delta change minus the touch slop value, otherwise this should return null. + */ + fun addPointerInputChange( + dragEvent: PointerInputChange, + touchSlop: Float + ): Offset? { + val currentPosition = dragEvent.position + val previousPosition = dragEvent.previousPosition + val positionChange = currentPosition - previousPosition + totalPositionChange += positionChange + + val inDirection = if (orientation == null) { + totalPositionChange.getDistance() + } else { + totalPositionChange.mainAxis().absoluteValue + } + + val hasCrossedSlop = inDirection >= touchSlop + + return if (hasCrossedSlop) { + calculatePostSlopOffset(touchSlop) + } else { + null + } + } + + /** + * Resets the accumulator associated with this detector. + */ + fun reset() { + totalPositionChange = Offset.Zero + } + + private fun calculatePostSlopOffset(touchSlop: Float): Offset { + return if (orientation == null) { + val touchSlopOffset = + totalPositionChange / totalPositionChange.getDistance() * touchSlop + // update postSlopOffset + totalPositionChange - touchSlopOffset + } else { + val finalMainAxisChange = totalPositionChange.mainAxis() - + (sign(totalPositionChange.mainAxis()) * touchSlop) + val finalCrossAxisChange = totalPositionChange.crossAxis() + if (orientation == Orientation.Horizontal) { + Offset(finalMainAxisChange, finalCrossAxisChange) + } else { + Offset(finalCrossAxisChange, finalMainAxisChange) + } + } + } +} + +/** + * Waits for a long press by examining [pointerId]. + * + * If that [pointerId] is raised (that is, the user lifts their finger), but another + * finger ([PointerId]) is down at that time, another pointer will be chosen as the lead for the + * gesture, and if none are down, `null` is returned. + * + * @return The latest [PointerInputChange] associated with a long press or `null` if all pointers + * are raised before a long press is detected or another gesture consumed the change. + * + * Example Usage: + * @sample androidx.compose.foundation.samples.AwaitLongPressOrCancellationSample + */ +suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation( + pointerId: PointerId +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the long press is cancelled. + } + + val initialDown = + currentEvent.changes.fastFirstOrNull { it.id == pointerId } ?: return null + + var longPress: PointerInputChange? = null + var currentDown = initialDown + val longPressTimeout = viewConfiguration.longPressTimeoutMillis + return try { + // wait for first tap up or long press + withTimeout(longPressTimeout) { + var finished = false + while (!finished) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) { + // All pointers are up + finished = true + } + + if ( + event.changes.fastAny { + it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) + } + ) { + finished = true // Canceled + } + + // Check for cancel by position consumption. We can look on the Final pass of + // the existing pointer event because it comes after the Main pass we checked + // above. + val consumeCheck = awaitPointerEvent(PointerEventPass.Final) + if (consumeCheck.changes.fastAny { it.isConsumed }) { + finished = true + } + if (event.isPointerUp(currentDown.id)) { + val newPressed = event.changes.fastFirstOrNull { it.pressed } + if (newPressed != null) { + currentDown = newPressed + longPress = currentDown + } else { + // should technically never happen as we checked it above + finished = true + } + // Pointer (id) stayed down. + } else { + longPress = event.changes.fastFirstOrNull { it.id == currentDown.id } + } + } + } + null + } catch (_: PointerEventTimeoutCancellationException) { + longPress ?: initialDown + } +} + +private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean = + changes.fastFirstOrNull { it.id == pointerId }?.pressed != true + +// This value was determined using experiments and common sense. +// We can't use zero slop, because some hypothetical desktop/mobile devices can send +// pointer events with a very high precision (but I haven't encountered any that send +// events with less than 1px precision) +private val mouseSlop = 0.125.dp +private val defaultTouchSlop = 18.dp // The default touch slop on Android devices +private val mouseToTouchSlopRatio = mouseSlop / defaultTouchSlop + +// TODO(demin): consider this as part of ViewConfiguration class after we make *PointerSlop* +// functions public (see the comment at the top of the file). +// After it will be a public API, we should get rid of `touchSlop / 144` and return absolute +// value 0.125.dp.toPx(). It is not possible right now, because we can't access density. +internal fun ViewConfiguration.pointerSlop(pointerType: PointerType): Float { + return when (pointerType) { + PointerType.Mouse -> touchSlop * mouseToTouchSlopRatio + else -> touchSlop + } +} diff --git a/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt new file mode 100644 index 0000000..a50a7e1 --- /dev/null +++ b/core/src/commonMain/kotlin/androidx/compose/foundation/gestures/Draggable.kt @@ -0,0 +1,648 @@ +// File copied from Compose Foundation 1.7.0 +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.gestures + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.DragEvent.DragCancelled +import androidx.compose.foundation.gestures.DragEvent.DragDelta +import androidx.compose.foundation.gestures.DragEvent.DragStarted +import androidx.compose.foundation.gestures.DragEvent.DragStopped +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.Velocity +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.sign +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * State of [draggable]. Allows for a granular control of how deltas are consumed by the user as + * well as to write custom drag methods using [drag] suspend function. + */ +//@JvmDefaultWithCompatibility +interface DraggableState { + /** + * Call this function to take control of drag logic. + * + * All actions that change the logical drag position must be performed within a [drag] + * block (even if they don't call any other methods on this object) in order to guarantee + * that mutual exclusion is enforced. + * + * If [drag] is called from elsewhere with the [dragPriority] higher or equal to ongoing + * drag, ongoing drag will be canceled. + * + * @param dragPriority of the drag operation + * @param block to perform drag in + */ + suspend fun drag( + dragPriority: MutatePriority = MutatePriority.Default, + block: suspend DragScope.() -> Unit + ) + + /** + * Dispatch drag delta in pixels avoiding all drag related priority mechanisms. + * + * **NOTE:** unlike [drag], dispatching any delta with this method will bypass scrolling of + * any priority. This method will also ignore `reverseDirection` and other parameters set in + * [draggable]. + * + * This method is used internally for low level operations, allowing implementers of + * [DraggableState] influence the consumption as suits them, e.g. introduce nested scrolling. + * Manually dispatching delta via this method will likely result in a bad user experience, + * you must prefer [drag] method over this one. + * + * @param delta amount of scroll dispatched in the nested drag process + */ + fun dispatchRawDelta(delta: Float) +} + +/** + * Scope used for suspending drag blocks + */ +interface DragScope { + /** + * Attempts to drag by [pixels] px. + */ + fun dragBy(pixels: Float) +} + +/** + * Default implementation of [DraggableState] interface that allows to pass a simple action that + * will be invoked when the drag occurs. + * + * This is the simplest way to set up a [draggable] modifier. When constructing this + * [DraggableState], you must provide a [onDelta] lambda, which will be invoked whenever + * drag happens (by gesture input or a custom [DraggableState.drag] call) with the delta in + * pixels. + * + * If you are creating [DraggableState] in composition, consider using [rememberDraggableState]. + * + * @param onDelta callback invoked when drag occurs. The callback receives the delta in pixels. + */ +fun DraggableState(onDelta: (Float) -> Unit): DraggableState = + DefaultDraggableState(onDelta) + +/** + * Create and remember default implementation of [DraggableState] interface that allows to pass a + * simple action that will be invoked when the drag occurs. + * + * This is the simplest way to set up a [draggable] modifier. When constructing this + * [DraggableState], you must provide a [onDelta] lambda, which will be invoked whenever + * drag happens (by gesture input or a custom [DraggableState.drag] call) with the delta in + * pixels. + * + * @param onDelta callback invoked when drag occurs. The callback receives the delta in pixels. + */ +@Composable +fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState { + val onDeltaState = rememberUpdatedState(onDelta) + return remember { DraggableState { onDeltaState.value.invoke(it) } } +} + +/** + * Configure touch dragging for the UI element in a single [Orientation]. The drag distance + * reported to [DraggableState], allowing users to react on the drag delta and update their state. + * + * The common usecase for this component is when you need to be able to drag something + * inside the component on the screen and represent this state via one float value + * + * If you need to control the whole dragging flow, consider using [pointerInput] instead with the + * helper functions like [detectDragGestures]. + * + * If you want to enable dragging in 2 dimensions, consider using [draggable2D]. + * + * If you are implementing scroll/fling behavior, consider using [scrollable]. + * + * @sample androidx.compose.foundation.samples.DraggableSample + * + * @param state [DraggableState] state of the draggable. Defines how drag events will be + * interpreted by the user land logic. + * @param orientation orientation of the drag + * @param enabled whether or not drag is enabled + * @param interactionSource [MutableInteractionSource] that will be used to emit + * [DragInteraction.Start] when this draggable is being dragged. + * @param startDragImmediately when set to true, draggable will start dragging immediately and + * prevent other gesture detectors from reacting to "down" events (in order to block composed + * press-based gestures). This is intended to allow end users to "catch" an animating widget by + * pressing on it. It's useful to set it when value you're dragging is settling / animating. + * @param onDragStarted callback that will be invoked when drag is about to start at the starting + * position, allowing user to suspend and perform preparation for drag, if desired. This suspend + * function is invoked with the draggable scope, allowing for async processing, if desired. Note + * that the scope used here is the one provided by the draggable node, for long running work that + * needs to outlast the modifier being in the composition you should use a scope that fits the + * lifecycle needed. + * @param onDragStopped callback that will be invoked when drag is finished, allowing the + * user to react on velocity and process it. This suspend function is invoked with the draggable + * scope, allowing for async processing, if desired. Note that the scope used here is the one + * provided by the draggable node, for long running work that needs to outlast the modifier being + * in the composition you should use a scope that fits the lifecycle needed. + * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will + * behave like bottom to top and left to right will behave like right to left. + */ +@Stable +fun Modifier.draggable( + state: DraggableState, + orientation: Orientation, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + startDragImmediately: Boolean = false, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = NoOpOnDragStarted, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = NoOpOnDragStopped, + reverseDirection: Boolean = false +): Modifier = this then DraggableElement( + state = state, + orientation = orientation, + enabled = enabled, + interactionSource = interactionSource, + startDragImmediately = startDragImmediately, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + reverseDirection = reverseDirection +) + +internal class DraggableElement( + private val state: DraggableState, + private val orientation: Orientation, + private val enabled: Boolean, + private val interactionSource: MutableInteractionSource?, + private val startDragImmediately: Boolean, + private val onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, + private val onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit, + private val reverseDirection: Boolean +) : ModifierNodeElement() { + override fun create(): DraggableNode = DraggableNode( + state, + CanDrag, + orientation, + enabled, + interactionSource, + startDragImmediately, + onDragStarted, + onDragStopped, + reverseDirection + ) + + override fun update(node: DraggableNode) { + node.update( + state, + CanDrag, + orientation, + enabled, + interactionSource, + startDragImmediately, + onDragStarted, + onDragStopped, + reverseDirection + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other === null) return false + if (this::class != other::class) return false + + other as DraggableElement + + if (state != other.state) return false + if (orientation != other.orientation) return false + if (enabled != other.enabled) return false + if (interactionSource != other.interactionSource) return false + if (startDragImmediately != other.startDragImmediately) return false + if (onDragStarted != other.onDragStarted) return false + if (onDragStopped != other.onDragStopped) return false + if (reverseDirection != other.reverseDirection) return false + + return true + } + + override fun hashCode(): Int { + var result = state.hashCode() + result = 31 * result + orientation.hashCode() + result = 31 * result + enabled.hashCode() + result = 31 * result + (interactionSource?.hashCode() ?: 0) + result = 31 * result + startDragImmediately.hashCode() + result = 31 * result + onDragStarted.hashCode() + result = 31 * result + onDragStopped.hashCode() + result = 31 * result + reverseDirection.hashCode() + return result + } + + override fun InspectorInfo.inspectableProperties() { + name = "draggable" + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseDirection"] = reverseDirection + properties["interactionSource"] = interactionSource + properties["startDragImmediately"] = startDragImmediately + properties["onDragStarted"] = onDragStarted + properties["onDragStopped"] = onDragStopped + properties["state"] = state + } + + companion object { + val CanDrag: (PointerInputChange) -> Boolean = { true } + } +} + +internal class DraggableNode( + private var state: DraggableState, + canDrag: (PointerInputChange) -> Boolean, + private var orientation: Orientation, + enabled: Boolean, + interactionSource: MutableInteractionSource?, + private var startDragImmediately: Boolean, + private var onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, + private var onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit, + private var reverseDirection: Boolean +) : DragGestureNode( + canDrag = canDrag, + enabled = enabled, + interactionSource = interactionSource, + orientationLock = orientation +) { + + override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) { + state.drag(MutatePriority.UserInput) { + forEachDelta { dragDelta -> + dragBy(dragDelta.delta.reverseIfNeeded().toFloat(orientation)) + } + } + } + + override fun onDragStarted(startedPosition: Offset) { + if (!isAttached || onDragStarted == NoOpOnDragStarted) return + coroutineScope.launch { + this@DraggableNode.onDragStarted(this, startedPosition) + } + } + + override fun onDragStopped(velocity: Velocity) { + if (!isAttached || onDragStopped == NoOpOnDragStopped) return + coroutineScope.launch { + this@DraggableNode.onDragStopped(this, velocity.reverseIfNeeded().toFloat(orientation)) + } + } + + override fun startDragImmediately(): Boolean = startDragImmediately + + fun update( + state: DraggableState, + canDrag: (PointerInputChange) -> Boolean, + orientation: Orientation, + enabled: Boolean, + interactionSource: MutableInteractionSource?, + startDragImmediately: Boolean, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit, + reverseDirection: Boolean + ) { + var resetPointerInputHandling = false + if (this.state != state) { + this.state = state + resetPointerInputHandling = true + } + if (this.orientation != orientation) { + this.orientation = orientation + resetPointerInputHandling = true + } + if (this.reverseDirection != reverseDirection) { + this.reverseDirection = reverseDirection + resetPointerInputHandling = true + } + + this.onDragStarted = onDragStarted + this.onDragStopped = onDragStopped + this.startDragImmediately = startDragImmediately + + update( + canDrag, + enabled, + interactionSource, + orientation, + resetPointerInputHandling + ) + } + + private fun Velocity.reverseIfNeeded() = if (reverseDirection) this * -1f else this * 1f + private fun Offset.reverseIfNeeded() = if (reverseDirection) this * -1f else this * 1f +} + +/** + * A node that performs drag gesture recognition and event propagation. + */ +internal abstract class DragGestureNode( + canDrag: (PointerInputChange) -> Boolean, + enabled: Boolean, + interactionSource: MutableInteractionSource?, + private var orientationLock: Orientation? +) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode { + + protected var canDrag = canDrag + private set + protected var enabled = enabled + private set + protected var interactionSource = interactionSource + private set + + // Use wrapper lambdas here to make sure that if these properties are updated while we suspend, + // we point to the new reference when we invoke them. startDragImmediately is a lambda since we + // need the most recent value passed to it from Scrollable. + private val _canDrag: (PointerInputChange) -> Boolean = { this.canDrag(it) } + private var channel: Channel? = null + private var dragInteraction: DragInteraction.Start? = null + private var isListeningForEvents = false + + /** + * Responsible for the dragging behavior between the start and the end of the drag. It + * continually invokes `forEachDelta` to process incoming events. In return, `forEachDelta` + * calls `dragBy` method to process each individual delta. + */ + abstract suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) + + /** + * Passes the action needed when a drag starts. This gives the ability to pass the desired + * behavior from other nodes implementing AbstractDraggableNode + */ + abstract fun onDragStarted(startedPosition: Offset) + + /** + * Passes the action needed when a drag stops. This gives the ability to pass the desired + * behavior from other nodes implementing AbstractDraggableNode + */ + abstract fun onDragStopped(velocity: Velocity) + + /** + * If touch slop recognition should be skipped. If this is true, this node will start + * recognizing drag events immediately without waiting for touch slop. + */ + abstract fun startDragImmediately(): Boolean + + private fun startListeningForEvents() { + isListeningForEvents = true + + /** + * To preserve the original behavior we had (before the Modifier.Node migration) we need to + * scope the DragStopped and DragCancel methods to the node's coroutine scope instead of using + * the one provided by the pointer input modifier, this is to ensure that even when the pointer + * input scope is reset we will continue any coroutine scope scope that we started from these + * methods while the pointer input scope was active. + */ + coroutineScope.launch { + while (isActive) { + var event = channel?.receive() + if (event !is DragStarted) continue + processDragStart(event) + try { + drag { processDelta -> + while (event !is DragStopped && event !is DragCancelled) { + (event as? DragDelta)?.let(processDelta) + event = channel?.receive() + } + } + if (event is DragStopped) { + processDragStop(event as DragStopped) + } else if (event is DragCancelled) { + processDragCancel() + } + } catch (c: CancellationException) { + processDragCancel() + } + } + } + } + + private var pointerInputNode: SuspendingPointerInputModifierNode? = null + + override fun onDetach() { + isListeningForEvents = false + disposeInteractionSource() + } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) { + if (enabled && pointerInputNode == null) { + pointerInputNode = delegate(initializePointerInputNode()) + } + pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds) + } + + private fun initializePointerInputNode(): SuspendingPointerInputModifierNode { + return SuspendingPointerInputModifierNode { + // re-create tracker when pointer input block restarts. This lazily creates the tracker + // only when it is need. + val velocityTracker = VelocityTracker() + val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit = + { startEvent, initialDelta -> + if (canDrag.invoke(startEvent)) { + if (!isListeningForEvents) { + if (channel == null) { + channel = Channel(capacity = Channel.UNLIMITED) + } + startListeningForEvents() + } + val overSlopOffset = initialDelta + val xSign = sign(startEvent.position.x) + val ySign = sign(startEvent.position.y) + val adjustedStart = startEvent.position - + Offset(overSlopOffset.x * xSign, overSlopOffset.y * ySign) + + channel?.trySend(DragStarted(adjustedStart)) + } + } + + val onDragEnd: (change: PointerInputChange) -> Unit = { upEvent -> + velocityTracker.addPointerInputChange(upEvent) + val maximumVelocity = currentValueOf(LocalViewConfiguration) + .maximumFlingVelocity + val velocity = velocityTracker.calculateVelocity( + Velocity(maximumVelocity, maximumVelocity) + ) + velocityTracker.resetTracking() + channel?.trySend(DragStopped(velocity.toValidVelocity())) + } + + val onDragCancel: () -> Unit = { + channel?.trySend(DragCancelled) + } + + val shouldAwaitTouchSlop: () -> Boolean = { + !startDragImmediately() + } + + val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit = + { change, delta -> + velocityTracker.addPointerInputChange(change) + channel?.trySend(DragDelta(delta)) + } + + coroutineScope { + try { + detectDragGestures( + orientationLock = orientationLock, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + shouldAwaitTouchSlop = shouldAwaitTouchSlop, + onDrag = onDrag + ) + } catch (cancellation: CancellationException) { + channel?.trySend(DragCancelled) + if (!isActive) throw cancellation + } + } + } + } + + override fun onCancelPointerInput() { + pointerInputNode?.onCancelPointerInput() + } + + private suspend fun processDragStart(event: DragStarted) { + dragInteraction?.let { oldInteraction -> + interactionSource?.emit(DragInteraction.Cancel(oldInteraction)) + } + val interaction = DragInteraction.Start() + interactionSource?.emit(interaction) + dragInteraction = interaction + onDragStarted(event.startPoint) + } + + private suspend fun processDragStop(event: DragStopped) { + dragInteraction?.let { interaction -> + interactionSource?.emit(DragInteraction.Stop(interaction)) + dragInteraction = null + } + onDragStopped(event.velocity) + } + + private suspend fun processDragCancel() { + dragInteraction?.let { interaction -> + interactionSource?.emit(DragInteraction.Cancel(interaction)) + dragInteraction = null + } + onDragStopped(Velocity.Zero) + } + + fun disposeInteractionSource() { + dragInteraction?.let { interaction -> + interactionSource?.tryEmit(DragInteraction.Cancel(interaction)) + dragInteraction = null + } + } + + fun update( + canDrag: (PointerInputChange) -> Boolean = this.canDrag, + enabled: Boolean = this.enabled, + interactionSource: MutableInteractionSource? = this.interactionSource, + orientationLock: Orientation? = this.orientationLock, + shouldResetPointerInputHandling: Boolean = false + ) { + var resetPointerInputHandling = shouldResetPointerInputHandling + + this.canDrag = canDrag + if (this.enabled != enabled) { + this.enabled = enabled + if (!enabled) { + disposeInteractionSource() + pointerInputNode?.let { undelegate(it) } + pointerInputNode = null + } + resetPointerInputHandling = true + } + if (this.interactionSource != interactionSource) { + disposeInteractionSource() + this.interactionSource = interactionSource + } + + if (this.orientationLock != orientationLock) { + this.orientationLock = orientationLock + resetPointerInputHandling = true + } + + if (resetPointerInputHandling) { + pointerInputNode?.resetPointerInputHandler() + } + } +} + +private class DefaultDraggableState(val onDelta: (Float) -> Unit) : DraggableState { + + private val dragScope: DragScope = object : DragScope { + override fun dragBy(pixels: Float): Unit = onDelta(pixels) + } + + private val scrollMutex = MutatorMutex() + + override suspend fun drag( + dragPriority: MutatePriority, + block: suspend DragScope.() -> Unit + ): Unit = coroutineScope { + scrollMutex.mutateWith(dragScope, dragPriority, block) + } + + override fun dispatchRawDelta(delta: Float) { + return onDelta(delta) + } +} + +internal sealed class DragEvent { + class DragStarted(val startPoint: Offset) : DragEvent() + class DragStopped(val velocity: Velocity) : DragEvent() + object DragCancelled : DragEvent() + class DragDelta(val delta: Offset) : DragEvent() +} + +private fun Offset.toFloat(orientation: Orientation) = + if (orientation == Orientation.Vertical) this.y else this.x + +private fun Velocity.toFloat(orientation: Orientation) = + if (orientation == Orientation.Vertical) this.y else this.x + +private fun Velocity.toValidVelocity() = + Velocity(if (this.x.isNaN()) 0f else this.x, if (this.y.isNaN()) 0f else this.y) + +private val NoOpOnDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {} +private val NoOpOnDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {} diff --git a/core/src/jsMain/kotlin/androidx/compose/foundation/Expect.js.kt b/core/src/jsMain/kotlin/androidx/compose/foundation/Expect.js.kt deleted file mode 100644 index f4ec5f2..0000000 --- a/core/src/jsMain/kotlin/androidx/compose/foundation/Expect.js.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.foundation - -import kotlin.coroutines.cancellation.CancellationException - -internal actual abstract class CorePlatformOptimizedCancellationException actual constructor( - message: String? -) : CancellationException(message) diff --git a/core/src/jvmMain/kotlin/androidx/compose/foundation/Expect.jvm.kt b/core/src/jvmMain/kotlin/androidx/compose/foundation/Expect.jvm.kt deleted file mode 100644 index f9da16b..0000000 --- a/core/src/jvmMain/kotlin/androidx/compose/foundation/Expect.jvm.kt +++ /dev/null @@ -1,33 +0,0 @@ -// ktlint-disable filename - -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.foundation - -import kotlinx.coroutines.CancellationException - -internal actual abstract class CorePlatformOptimizedCancellationException actual constructor( - message: String? -) : CancellationException(message) { - - override fun fillInStackTrace(): Throwable { - // Avoid null.clone() on Android <= 6.0 when accessing stackTrace - stackTrace = emptyArray() - return this - } - -} diff --git a/core/src/jvmTest/kotlin/BottomSheetTests.kt b/core/src/jvmTest/kotlin/BottomSheetTests.kt index 6299f90..a5d35b1 100644 --- a/core/src/jvmTest/kotlin/BottomSheetTests.kt +++ b/core/src/jvmTest/kotlin/BottomSheetTests.kt @@ -1,5 +1,6 @@ package com.composables.core +import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.LaunchedEffect @@ -26,7 +27,10 @@ class BottomSheetTests { @Test fun sheetWithInitialDetentHidden_isNotDisplayed() = runComposeUiTest { setContent { - BottomSheet(rememberBottomSheetState(initialDetent = SheetDetent.Hidden)) { + BottomSheet(rememberBottomSheetState( + initialDetent = SheetDetent.Hidden, + decayAnimationSpec = rememberSplineBasedDecay() + )) { Box(Modifier.testTag("sheet_contents").size(40.dp)) } } @@ -37,7 +41,10 @@ class BottomSheetTests { @Test fun sheetWithInitialDetentFullyExpanded_isDisplayed() = runComposeUiTest { setContent { - BottomSheet(rememberBottomSheetState(initialDetent = SheetDetent.FullyExpanded)) { + BottomSheet(rememberBottomSheetState( + initialDetent = SheetDetent.FullyExpanded, + decayAnimationSpec = rememberSplineBasedDecay() + )) { Box(Modifier.testTag("sheet_contents").size(40.dp)) } } @@ -49,7 +56,10 @@ class BottomSheetTests { @Test fun settingDetentToFullyDetent_whenInitialIsDetentHidden_isDisplayed() = runComposeUiTest { setContent { - val state = rememberBottomSheetState(initialDetent = SheetDetent.Hidden) + val state = rememberBottomSheetState( + initialDetent = SheetDetent.Hidden, + decayAnimationSpec = rememberSplineBasedDecay() + ) LaunchedEffect(Unit) { state.currentDetent = SheetDetent.FullyExpanded @@ -72,7 +82,10 @@ class BottomSheetTests { contentSize = 150.dp } - val state = rememberBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + val state = rememberBottomSheetState( + initialDetent = SheetDetent.FullyExpanded, + decayAnimationSpec = rememberSplineBasedDecay() + ) BottomSheet(state, Modifier.testTag("sheet")) { Box(Modifier.testTag("sheet_contents").size(contentSize))