diff --git a/strict-mode/src/androidMain/kotlin/com/alexvanyo/composelife/strictmode/StrictModeExtensions.kt b/strict-mode/src/androidMain/kotlin/com/alexvanyo/composelife/strictmode/StrictModeExtensions.kt index 71395b2bf9..85a0274cee 100644 --- a/strict-mode/src/androidMain/kotlin/com/alexvanyo/composelife/strictmode/StrictModeExtensions.kt +++ b/strict-mode/src/androidMain/kotlin/com/alexvanyo/composelife/strictmode/StrictModeExtensions.kt @@ -25,7 +25,7 @@ import com.alexvanyo.composelife.logging.e fun Application.initStrictModeIfNeeded() { if (isDebuggable) { - //initStrictMode() + initStrictMode() } } diff --git a/ui-app/src/androidInstrumentedTest/kotlin/com/alexvanyo/composelife/ui/app/action/LoadedCellStatePreviewTests.kt b/ui-app/src/androidInstrumentedTest/kotlin/com/alexvanyo/composelife/ui/app/action/LoadedCellStatePreviewTests.kt index d2a99dae45..9f1aec008a 100644 --- a/ui-app/src/androidInstrumentedTest/kotlin/com/alexvanyo/composelife/ui/app/action/LoadedCellStatePreviewTests.kt +++ b/ui-app/src/androidInstrumentedTest/kotlin/com/alexvanyo/composelife/ui/app/action/LoadedCellStatePreviewTests.kt @@ -97,8 +97,8 @@ class LoadedCellStatePreviewTests : BaseUiInjectTest + droppedCellState = cellState } .size(100.dp) .background(Color.Blue), @@ -141,8 +141,8 @@ class LoadedCellStatePreviewTests : BaseUiInjectTest Unit, + setSelectionToCellState: (dropOffset: Offset, cellState: CellState) -> Unit, ): Modifier { val coroutineScope = rememberCoroutineScope() val dragAndDropEvents = remember { Channel(Channel.UNLIMITED) } val currentSetSelectionToCellState by rememberUpdatedState(setSelectionToCellState) - var positionOnScreen by remember { + var positionInWindow by remember { mutableStateOf(Offset.Zero) } @@ -128,7 +128,7 @@ actual fun Modifier.cellStateDragAndDropTarget( is DeserializationResult.Successful -> { currentSetSelectionToCellState( offset, - deserializationResult.cellState + deserializationResult.cellState, ) } is DeserializationResult.Unsuccessful -> { @@ -184,8 +184,8 @@ actual fun Modifier.cellStateDragAndDropTarget( override fun onStarted(event: DragAndDropEvent) { dragAndDropEvents.trySend( CellStateDragAndDropEvent.Started( - clipData = event.toAndroidDragEvent().clipData ?: - event.toAndroidDragEvent().localState as? ClipData, + clipData = event.toAndroidDragEvent().clipData + ?: event.toAndroidDragEvent().localState as? ClipData, ), ) } @@ -194,12 +194,12 @@ actual fun Modifier.cellStateDragAndDropTarget( val androidDragEvent = event.toAndroidDragEvent() dragAndDropEvents.trySend( CellStateDragAndDropEvent.Entered( - clipData = event.toAndroidDragEvent().clipData ?: - event.toAndroidDragEvent().localState as? ClipData, + clipData = event.toAndroidDragEvent().clipData + ?: event.toAndroidDragEvent().localState as? ClipData, offset = Offset( androidDragEvent.x, androidDragEvent.y, - ) - positionOnScreen, + ) - positionInWindow, ), ) } @@ -208,12 +208,12 @@ actual fun Modifier.cellStateDragAndDropTarget( val androidDragEvent = event.toAndroidDragEvent() dragAndDropEvents.trySend( CellStateDragAndDropEvent.Moved( - clipData = event.toAndroidDragEvent().clipData ?: - event.toAndroidDragEvent().localState as? ClipData, + clipData = event.toAndroidDragEvent().clipData + ?: event.toAndroidDragEvent().localState as? ClipData, offset = Offset( androidDragEvent.x, androidDragEvent.y, - ) - positionOnScreen, + ) - positionInWindow, ), ) } @@ -221,8 +221,8 @@ actual fun Modifier.cellStateDragAndDropTarget( override fun onExited(event: DragAndDropEvent) { dragAndDropEvents.trySend( CellStateDragAndDropEvent.Exited( - clipData = event.toAndroidDragEvent().clipData ?: - event.toAndroidDragEvent().localState as? ClipData, + clipData = event.toAndroidDragEvent().clipData + ?: event.toAndroidDragEvent().localState as? ClipData, ), ) } @@ -230,8 +230,8 @@ actual fun Modifier.cellStateDragAndDropTarget( override fun onEnded(event: DragAndDropEvent) { dragAndDropEvents.trySend( CellStateDragAndDropEvent.Ended( - clipData = event.toAndroidDragEvent().clipData ?: - event.toAndroidDragEvent().localState as? ClipData, + clipData = event.toAndroidDragEvent().clipData + ?: event.toAndroidDragEvent().localState as? ClipData, ), ) } @@ -248,7 +248,7 @@ actual fun Modifier.cellStateDragAndDropTarget( } return onGloballyPositioned { coordinates -> - positionOnScreen = coordinates.positionOnScreen() + positionInWindow = coordinates.positionInWindow() } .dragAndDropTarget( shouldStartDragAndDrop = { event -> diff --git a/ui-cells/src/androidMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.android.kt b/ui-cells/src/androidMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.android.kt similarity index 100% rename from ui-cells/src/androidMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.android.kt rename to ui-cells/src/androidMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.android.kt diff --git a/ui-cells/src/desktopMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.desktop.kt b/ui-cells/src/desktopMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.desktop.kt similarity index 100% rename from ui-cells/src/desktopMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.desktop.kt rename to ui-cells/src/desktopMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.desktop.kt diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDragAndDrop.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDragAndDrop.kt index b863f02d44..9ac55445d9 100644 --- a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDragAndDrop.kt +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDragAndDrop.kt @@ -32,6 +32,7 @@ expect fun Modifier.cellStateDragAndDropSource(getCellState: () -> CellState): M */ context(CellStateParserProvider) @Composable +@Suppress("ComposeComposableModifier") expect fun Modifier.cellStateDragAndDropTarget( mutableCellStateDropStateHolder: MutableCellStateDropStateHolder = rememberMutableCellStateDropStateHolder(), setSelectionToCellState: (dropOffset: Offset, cellState: CellState) -> Unit, diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDropState.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDropState.kt index eb7e03e010..804402fddc 100644 --- a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDropState.kt +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellStateDropState.kt @@ -66,7 +66,6 @@ fun rememberMutableCellStateDropStateHolder(): MutableCellStateDropStateHolder { set(value) { cellStateDropState = value } - } } } diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellWindow.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellWindow.kt index a16ae251c7..fe8fa06c35 100644 --- a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellWindow.kt +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/CellWindow.kt @@ -433,7 +433,7 @@ private fun CellWindowImpl( scaledCellDpSize = scaledCellDpSize, cellWindow = cellWindow, pixelOffsetFromCenter = fracPixelOffsetFromCenter, - modifier = Modifier + modifier = Modifier, ) } diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.kt new file mode 100644 index 0000000000..579d38967b --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/DashedDrawing.kt @@ -0,0 +1,78 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.annotation.FloatRange +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke + +/** + * Draws a dashed line rectangle, with intervals and phase applied symmetrically. + * + * The dashed line effect is drawn in 8 line segments: from the middle of each side, + * to each corner. + */ +internal fun DrawScope.drawDashedRect( + selectionColor: Color, + strokeWidth: Float, + intervals: FloatArray, + phase: Float = 0f, + rect: Rect = size.toRect(), +) { + // Draw the selection outlines in a way that is symmetric, and hides the extra of the lines near the + // corners with the selection handles + listOf( + rect.topCenter to rect.topLeft, + rect.topCenter to rect.topRight, + rect.centerLeft to rect.topLeft, + rect.centerLeft to rect.bottomLeft, + rect.bottomCenter to rect.bottomLeft, + rect.bottomCenter to rect.bottomRight, + rect.centerRight to rect.topRight, + rect.centerRight to rect.bottomRight, + ).forEach { (start, end) -> + drawDashedLine( + color = selectionColor, + start = start, + end = end, + strokeWidth = strokeWidth, + intervals = intervals, + phase = phase, + ) + } +} + +@Suppress("LongParameterList") +internal expect fun DrawScope.drawDashedLine( + color: Color, + start: Offset, + end: Offset, + intervals: FloatArray, + phase: Float = 0f, + strokeWidth: Float = Stroke.HairlineWidth, + cap: StrokeCap = Stroke.DefaultCap, + @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, + colorFilter: ColorFilter? = null, + blendMode: BlendMode = DrawScope.DefaultBlendMode, +) diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/FixedSelectingBoxOverlay.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/FixedSelectingBoxOverlay.kt new file mode 100644 index 0000000000..1e6d8ff986 --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/FixedSelectingBoxOverlay.kt @@ -0,0 +1,401 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector2D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.util.packInts +import androidx.compose.ui.util.unpackInt1 +import androidx.compose.ui.util.unpackInt2 +import com.alexvanyo.composelife.model.CellState +import com.alexvanyo.composelife.model.CellWindow +import com.alexvanyo.composelife.parameterizedstring.parameterizedStringResolver +import com.alexvanyo.composelife.sessionvalue.SessionValue +import com.alexvanyo.composelife.ui.cells.resources.SelectingBoxHandle +import com.alexvanyo.composelife.ui.cells.resources.Strings +import com.alexvanyo.composelife.ui.util.AnchoredDraggable2DState +import com.alexvanyo.composelife.ui.util.anchoredDraggable2D +import com.alexvanyo.composelife.ui.util.snapTo +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.min + +@Stable +private class HandleState( + val state: AnchoredDraggable2DState, +) { + var reentrancyCount by mutableStateOf(0) +} + +/** + * Computes the initial handles for a [SelectionState.SelectingBox.FixedSelectingBox]. + * + * If there is a [SelectionState.SelectingBox.FixedSelectingBox.previousTransientSelectingBox], then these handles + * will follow those offsets (to be transiently adjusted back to the rounded values). + * + * Otherwise, it will just be the direct fixed values. + */ +private val SelectionState.SelectingBox.FixedSelectingBox.initialHandles get(): List { + val initialHandleAOffset: Offset + val initialHandleBOffset: Offset + val initialHandleCOffset: Offset + val initialHandleDOffset: Offset + + if (previousTransientSelectingBox != null) { + initialHandleAOffset = previousTransientSelectingBox.rect.topLeft + initialHandleBOffset = previousTransientSelectingBox.rect.topRight + initialHandleCOffset = previousTransientSelectingBox.rect.bottomRight + initialHandleDOffset = previousTransientSelectingBox.rect.bottomLeft + } else { + initialHandleAOffset = topLeft.toOffset() + initialHandleBOffset = (topLeft + IntOffset(width, 0)).toOffset() + initialHandleCOffset = (topLeft + IntOffset(width, height)).toOffset() + initialHandleDOffset = (topLeft + IntOffset(0, height)).toOffset() + } + + return listOf( + initialHandleAOffset, + initialHandleBOffset, + initialHandleCOffset, + initialHandleDOffset, + ) +} + +/** + * The overlay for a [SelectionState.SelectingBox] [selectionState]. + * + * This includes the selection box, along with 4 handles draggable in two dimensions to allow changing the selecting + * box bounds. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList") +@Composable +internal fun FixedSelectingBoxOverlay( + selectionSessionState: SessionValue, + setSelectionState: (SelectionState) -> Unit, + getSelectionCellState: () -> CellState, + scaledCellPixelSize: Float, + cellWindow: CellWindow, + modifier: Modifier = Modifier, +) { + Box(modifier) { + /** + * The initial handles to initialize the handles with. + */ + /** + * The initial handles to initialize the handles with. + */ + val initialHandles = selectionSessionState.value.initialHandles + + /** + * The [DraggableAnchors2D] aligned to the current grid. + */ + /** + * The [DraggableAnchors2D] aligned to the current grid. + */ + val handleAnchors = remember(scaledCellPixelSize, cellWindow) { + GridDraggableAnchors2d(scaledCellPixelSize, cellWindow) + } + + /** + * State holders for the value change confirmation lambdas. + * + * These are initialized with a placeholder method, since this depends on the state of the other handles. + */ + /** + * State holders for the value change confirmation lambdas. + * + * These are initialized with a placeholder method, since this depends on the state of the other handles. + */ + val confirmValueChangeStates = List(initialHandles.size) { index -> + key(index) { + remember { mutableStateOf({ _: IntOffset -> true }) } + } + } + + val coroutineScope = rememberCoroutineScope() + + /** + * A list of [Animatable]s for each handle representing the fractional part of the initial handle value, in + * cell coordinates. + * + * This will be initially added to the offset calculations, and animated to zero. + */ + /** + * A list of [Animatable]s for each handle representing the fractional part of the initial handle value, in + * cell coordinates. + * + * This will be initially added to the offset calculations, and animated to zero. + */ + val transientSelectingBoxAnimatables = initialHandles.mapIndexed { index, offset -> + key(index) { + remember { + Animatable( + initialValue = offset - offset.round().toOffset(), + typeConverter = Offset.VectorConverter, + visibilityThreshold = Offset.VisibilityThreshold / scaledCellPixelSize, + ) + } + } + } + + // Resolve the transient offsets to zero + transientSelectingBoxAnimatables.forEachIndexed { index, animatable -> + key(index) { + LaunchedEffect(animatable) { + animatable.animateTo(Offset.Zero) + } + } + } + + @Stable + class SelectionDraggableHandleState( + val state: HandleState, + transientSelectingBoxAnimatable: Animatable, + val horizontalPairState: HandleState, + val verticalPairState: HandleState, + val oppositeCornerState: HandleState, + ) { + val confirmValueChange: (IntOffset) -> Boolean = { intOffset -> + if (state.reentrancyCount == 0) { + val minX = min(intOffset.x, oppositeCornerState.state.targetValue.x) + val maxX = max(intOffset.x, oppositeCornerState.state.targetValue.x) + val minY = min(intOffset.y, oppositeCornerState.state.targetValue.y) + val maxY = max(intOffset.y, oppositeCornerState.state.targetValue.y) + + setSelectionState( + SelectionState.SelectingBox.FixedSelectingBox( + topLeft = IntOffset(minX, minY), + width = maxX - minX, + height = maxY - minY, + previousTransientSelectingBox = null, + ), + ) + + coroutineScope.launch { + try { + horizontalPairState.reentrancyCount++ + horizontalPairState.state.snapTo( + IntOffset( + x = horizontalPairState.state.targetValue.x, + y = intOffset.y, + ), + ) + } finally { + horizontalPairState.reentrancyCount-- + } + } + coroutineScope.launch { + try { + verticalPairState.reentrancyCount++ + verticalPairState.state.snapTo( + IntOffset( + x = intOffset.x, + y = verticalPairState.state.targetValue.y, + ), + ) + } finally { + verticalPairState.reentrancyCount-- + } + } + } + true + } + + val offsetCalculator: () -> Offset = { + val xReferenceState = when { + state.state.isDraggingOrAnimating() -> state + verticalPairState.state.isDraggingOrAnimating() -> verticalPairState + else -> state + } + val yReferenceState = when { + state.state.isDraggingOrAnimating() -> state + horizontalPairState.state.isDraggingOrAnimating() -> horizontalPairState + else -> state + } + + Offset( + xReferenceState.state.requireOffset().x, + yReferenceState.state.requireOffset().y, + ) + transientSelectingBoxAnimatable.value * scaledCellPixelSize + } + } + + val handleAnchoredDraggable2DStates = + initialHandles.mapIndexed { index, initialHandleOffset -> + key(index, scaledCellPixelSize, cellWindow) { + rememberSaveable( + saver = Saver( + save = { packInts(it.currentValue.x, it.currentValue.y) }, + restore = { + AnchoredDraggable2DState( + initialValue = IntOffset(unpackInt1(it), unpackInt2(it)), + animationSpec = spring(), + confirmValueChange = { intOffset -> + confirmValueChangeStates[index].value.invoke(intOffset) + }, + ) + }, + ), + ) { + AnchoredDraggable2DState( + initialValue = initialHandleOffset.round(), + animationSpec = spring(), + confirmValueChange = { intOffset -> + confirmValueChangeStates[index].value.invoke(intOffset) + }, + ) + }.apply { + updateAnchors( + newAnchors = handleAnchors, + newTarget = targetValue, + ) + } + } + } + + val handleStates = handleAnchoredDraggable2DStates.mapIndexed { index, handleAnchoredDraggable2DState -> + key(index) { + remember(handleAnchoredDraggable2DState) { + HandleState(handleAnchoredDraggable2DState) + } + } + } + + val handleAState = handleStates[0] + val handleBState = handleStates[1] + val handleCState = handleStates[2] + val handleDState = handleStates[3] + + val selectionHandleStates = remember(handleStates, transientSelectingBoxAnimatables) { + listOf( + SelectionDraggableHandleState( + state = handleAState, + transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[0], + horizontalPairState = handleBState, + verticalPairState = handleDState, + oppositeCornerState = handleCState, + ), + SelectionDraggableHandleState( + state = handleBState, + transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[1], + horizontalPairState = handleAState, + verticalPairState = handleCState, + oppositeCornerState = handleDState, + ), + SelectionDraggableHandleState( + state = handleCState, + transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[2], + horizontalPairState = handleDState, + verticalPairState = handleBState, + oppositeCornerState = handleAState, + ), + SelectionDraggableHandleState( + state = handleDState, + transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[3], + horizontalPairState = handleCState, + verticalPairState = handleAState, + oppositeCornerState = handleBState, + ), + ) + } + + selectionHandleStates.forEachIndexed { index, selectionDraggableHandleState -> + confirmValueChangeStates[index].value = selectionDraggableHandleState.confirmValueChange + } + + SelectingBox( + modifier = Modifier + .fillMaxSize() + .boxLayoutByHandles( + handleAOffsetCalculator = selectionHandleStates[0].offsetCalculator, + handleBOffsetCalculator = selectionHandleStates[1].offsetCalculator, + handleCOffsetCalculator = selectionHandleStates[2].offsetCalculator, + handleDOffsetCalculator = selectionHandleStates[3].offsetCalculator, + ) + .cellStateDragAndDropSource(getSelectionCellState), + ) + + val parameterizedStringResolver = parameterizedStringResolver() + + selectionHandleStates + .forEachIndexed { index, selectionDraggableHandleState -> + key(index) { + val interactionSource = remember { MutableInteractionSource() } + + val isDragged by interactionSource.collectIsDraggedAsState() + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + + val isActive = isDragged || isHovered || isPressed + + SelectionHandle( + isActive = isActive, + modifier = Modifier + .offset { + selectionDraggableHandleState.offsetCalculator().round() + } + .graphicsLayer { + translationX = -size.width / 2f + translationY = -size.height / 2f + } + .anchoredDraggable2D( + state = selectionDraggableHandleState.state.state, + interactionSource = interactionSource, + ) + .semantics { + val targetValue = selectionDraggableHandleState.state.state.targetValue + contentDescription = parameterizedStringResolver( + Strings.SelectingBoxHandle( + targetValue.x, + targetValue.y, + ), + ) + }, + ) + } + } + } +} diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/GridDraggableAnchors2d.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/GridDraggableAnchors2d.kt new file mode 100644 index 0000000000..6b3fbed97d --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/GridDraggableAnchors2d.kt @@ -0,0 +1,38 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toOffset +import com.alexvanyo.composelife.model.CellWindow +import com.alexvanyo.composelife.ui.util.DraggableAnchors2D + +internal data class GridDraggableAnchors2d( + private val scaledCellPixelSize: Float, + private val cellWindow: CellWindow, +) : DraggableAnchors2D { + override fun positionOf(anchor: IntOffset): Offset = + (anchor.toOffset() - cellWindow.topLeft.toOffset()) * scaledCellPixelSize + + override fun hasPositionFor(anchor: IntOffset): Boolean = true + override fun closestAnchor(position: Offset): IntOffset = + (position / scaledCellPixelSize).round() + cellWindow.topLeft + + override val size: Int = Int.MAX_VALUE +} diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectingBox.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectingBox.kt new file mode 100644 index 0000000000..a30b0e582f --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectingBox.kt @@ -0,0 +1,53 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * The selecting box itself. + */ +@Composable +fun SelectingBox( + // noinspection ComposeModifierWithoutDefault + modifier: Modifier, + selectionColor: Color = MaterialTheme.colorScheme.secondary, +) { + Canvas( + modifier = modifier.fillMaxSize(), + ) { + drawRect( + color = selectionColor, + alpha = 0.2f, + ) + drawDashedRect( + selectionColor = selectionColor, + strokeWidth = 2.dp.toPx(), + intervals = floatArrayOf( + 24.dp.toPx(), + 24.dp.toPx(), + ), + phase = 12.dp.toPx(), + ) + } +} diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionBoxOverlay.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionBoxOverlay.kt new file mode 100644 index 0000000000..e5b56b0bf5 --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionBoxOverlay.kt @@ -0,0 +1,143 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.util.packInts +import androidx.compose.ui.util.unpackInt1 +import androidx.compose.ui.util.unpackInt2 +import com.alexvanyo.composelife.model.CellState +import com.alexvanyo.composelife.model.CellWindow +import com.alexvanyo.composelife.sessionvalue.SessionValue +import com.alexvanyo.composelife.ui.util.AnchoredDraggable2DState +import com.alexvanyo.composelife.ui.util.anchoredDraggable2D +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach + +@Suppress("LongParameterList", "LongMethod") +@Composable +internal fun SelectionBoxOverlay( + selectionSessionState: SessionValue, + setSelectionState: (SelectionState) -> Unit, + getSelectionCellState: () -> CellState, + scaledCellPixelSize: Float, + cellWindow: CellWindow, + modifier: Modifier = Modifier, +) { + val handleAnchors = remember(scaledCellPixelSize, cellWindow) { + GridDraggableAnchors2d(scaledCellPixelSize, cellWindow) + } + + val initialOffset = selectionSessionState.value.offset + + val currentSelectionSessionState by rememberUpdatedState(selectionSessionState) + val currentSetSelectionState by rememberUpdatedState(setSelectionState) + + val confirmValueChange = { intOffset: IntOffset -> + setSelectionState( + currentSelectionSessionState.value.copy( + offset = intOffset, + ), + ) + true + } + + val draggable2DState = rememberSaveable( + saver = Saver( + save = { packInts(it.currentValue.x, it.currentValue.y) }, + restore = { + AnchoredDraggable2DState( + initialValue = IntOffset(unpackInt1(it), unpackInt2(it)), + animationSpec = spring(), + confirmValueChange = confirmValueChange, + ) + }, + ), + ) { + AnchoredDraggable2DState( + initialValue = initialOffset, + animationSpec = spring(), + confirmValueChange = confirmValueChange, + ) + }.apply { + updateAnchors( + newAnchors = handleAnchors, + // Ensure the target value remains the same due to updating the anchors. + // This keeps the selection box stationary relative to the overall universe. + newTarget = targetValue, + ) + } + + // As a side-effect of dragging, update the underlying selection state to match the intermediate selection. + LaunchedEffect(draggable2DState) { + snapshotFlow { draggable2DState.currentValue } + .onEach { intOffset -> + currentSetSelectionState( + currentSelectionSessionState.value.copy( + offset = intOffset, + ), + ) + } + .collect() + } + + val boundingBox = selectionSessionState.value.cellState.boundingBox + + SelectingBox( + modifier = modifier + .fillMaxSize() + .boxLayoutByHandles( + handleAOffsetCalculator = { + draggable2DState.requireOffset() + }, + handleBOffsetCalculator = { + draggable2DState.requireOffset() + Offset( + boundingBox.width.toFloat(), + 0f, + ) * scaledCellPixelSize + }, + handleCOffsetCalculator = { + draggable2DState.requireOffset() + Offset( + boundingBox.width.toFloat(), + boundingBox.height.toFloat(), + ) * scaledCellPixelSize + }, + handleDOffsetCalculator = { + draggable2DState.requireOffset() + Offset( + 0f, + boundingBox.height.toFloat(), + ) * scaledCellPixelSize + }, + ) + .cellStateDragAndDropSource(getSelectionCellState) + .anchoredDraggable2D(draggable2DState), + selectionColor = MaterialTheme.colorScheme.tertiary, + ) +} diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionHandle.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionHandle.kt new file mode 100644 index 0000000000..a4b93b7928 --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionHandle.kt @@ -0,0 +1,54 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A selection handle. + */ +@Composable +fun SelectionHandle( + isActive: Boolean, + modifier: Modifier = Modifier, + selectionColor: Color = MaterialTheme.colorScheme.secondary, +) { + Box( + modifier = modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + val size by animateDpAsState(if (isActive) 24.dp else 16.dp) + val elevation by animateDpAsState(if (isActive) 4.dp else 0.dp) + Surface( + modifier = Modifier.size(size), + shape = CircleShape, + shadowElevation = elevation, + color = selectionColor, + ) {} + } +} diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.kt index aece7dda0f..b6240fc703 100644 --- a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.kt +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlay.kt @@ -16,92 +16,40 @@ package com.alexvanyo.composelife.ui.cells -import androidx.annotation.FloatRange -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector2D -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsDraggedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.center -import androidx.compose.ui.geometry.toRect -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toIntRect import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toRect import androidx.compose.ui.unit.toSize -import androidx.compose.ui.util.packInts -import androidx.compose.ui.util.unpackInt1 -import androidx.compose.ui.util.unpackInt2 import com.alexvanyo.composelife.geometry.times import com.alexvanyo.composelife.model.CellState import com.alexvanyo.composelife.model.CellWindow import com.alexvanyo.composelife.model.di.CellStateParserProvider -import com.alexvanyo.composelife.parameterizedstring.parameterizedStringResolver import com.alexvanyo.composelife.sessionvalue.SessionValue import com.alexvanyo.composelife.sessionvalue.preLocalSessionId import com.alexvanyo.composelife.sessionvalue.rememberSessionValueHolder -import com.alexvanyo.composelife.ui.cells.resources.SelectingBoxHandle -import com.alexvanyo.composelife.ui.cells.resources.Strings import com.alexvanyo.composelife.ui.util.AnchoredDraggable2DState import com.alexvanyo.composelife.ui.util.AnimatedContent -import com.alexvanyo.composelife.ui.util.DraggableAnchors2D import com.alexvanyo.composelife.ui.util.TargetState -import com.alexvanyo.composelife.ui.util.anchoredDraggable2D -import com.alexvanyo.composelife.ui.util.snapTo -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -111,7 +59,7 @@ import kotlin.uuid.Uuid * The overlay based on the [selectionSessionState]. */ context(CellStateParserProvider) -@Suppress("LongMethod", "LongParameterList") +@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod") @Composable fun SelectionOverlay( selectionSessionState: SessionValue, @@ -156,7 +104,8 @@ fun SelectionOverlay( when (cellStateDropStateHolder.cellStateDropState) { CellStateDropState.ApplicableDropAvailable, - is CellStateDropState.DropPreview -> { + is CellStateDropState.DropPreview, + -> { drawDashedRect( selectionColor = dropAvailableBorderColor, strokeWidth = 12.dp.toPx(), @@ -187,10 +136,12 @@ fun SelectionOverlay( valueId = Uuid.random(), value = SelectionState.Selection( cellState = cellState, - offset = (cellWindow.topLeft.toOffset() + (dropOffset / scaledCellPixelSize) - - cellState.boundingBox.size.toSize().center).round(), + offset = ( + cellWindow.topLeft.toOffset() + (dropOffset / scaledCellPixelSize) - + cellState.boundingBox.size.toSize().center + ).round(), ), - ) + ), ) } .drawWithContent { @@ -243,18 +194,16 @@ fun SelectionOverlay( is SelectionState.Selection -> { @Suppress("UNCHECKED_CAST") - ( - SelectionBoxOverlay( - selectionSessionState = targetSelectionSessionState as SessionValue, - setSelectionState = selectionSessionStateValueHolder::setValue, - getSelectionCellState = { - getSelectionCellState(targetSelectionState) - }, - scaledCellPixelSize = with(LocalDensity.current) { scaledCellDpSize.toPx() }, - cellWindow = cellWindow, - modifier = Modifier.fillMaxSize(), - ) - ) + SelectionBoxOverlay( + selectionSessionState = targetSelectionSessionState as SessionValue, + setSelectionState = selectionSessionStateValueHolder::setValue, + getSelectionCellState = { + getSelectionCellState(targetSelectionState) + }, + scaledCellPixelSize = with(LocalDensity.current) { scaledCellDpSize.toPx() }, + cellWindow = cellWindow, + modifier = Modifier.fillMaxSize(), + ) } } } @@ -273,415 +222,15 @@ private fun ContentDrawScope.drawDropPreview( 12.dp.toPx(), ), phase = 12.dp.toPx(), - rect = ((dropPreview.cellState.boundingBox.size.toIntRect().toRect() - .translate(-dropPreview.cellState.boundingBox.size.toSize().center) - ) * scaledCellDpSize.toPx()).translate(dropPreview.offset) + rect = ( + ( + dropPreview.cellState.boundingBox.size.toIntRect().toRect() + .translate(-dropPreview.cellState.boundingBox.size.toSize().center) + ) * scaledCellDpSize.toPx() + ).translate(dropPreview.offset), ) } -/** - * Computes the initial handles for a [SelectionState.SelectingBox.FixedSelectingBox]. - * - * If there is a [SelectionState.SelectingBox.FixedSelectingBox.previousTransientSelectingBox], then these handles - * will follow those offsets (to be transiently adjusted back to the rounded values). - * - * Otherwise, it will just be the direct fixed values. - */ -private val SelectionState.SelectingBox.FixedSelectingBox.initialHandles get(): List { - val initialHandleAOffset: Offset - val initialHandleBOffset: Offset - val initialHandleCOffset: Offset - val initialHandleDOffset: Offset - - if (previousTransientSelectingBox != null) { - initialHandleAOffset = previousTransientSelectingBox.rect.topLeft - initialHandleBOffset = previousTransientSelectingBox.rect.topRight - initialHandleCOffset = previousTransientSelectingBox.rect.bottomRight - initialHandleDOffset = previousTransientSelectingBox.rect.bottomLeft - } else { - initialHandleAOffset = topLeft.toOffset() - initialHandleBOffset = (topLeft + IntOffset(width, 0)).toOffset() - initialHandleCOffset = (topLeft + IntOffset(width, height)).toOffset() - initialHandleDOffset = (topLeft + IntOffset(0, height)).toOffset() - } - - return listOf( - initialHandleAOffset, - initialHandleBOffset, - initialHandleCOffset, - initialHandleDOffset, - ) -} - -/** - * The overlay for a [SelectionState.SelectingBox] [selectionState]. - * - * This includes the selection box, along with 4 handles draggable in two dimensions to allow changing the selecting - * box bounds. - */ -@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList") -@Composable -private fun FixedSelectingBoxOverlay( - selectionSessionState: SessionValue, - setSelectionState: (SelectionState) -> Unit, - getSelectionCellState: () -> CellState, - scaledCellPixelSize: Float, - cellWindow: CellWindow, - modifier: Modifier = Modifier, -) { - Box(modifier) { - /** - * The initial handles to initialize the handles with. - */ - val initialHandles = selectionSessionState.value.initialHandles - - /** - * The [DraggableAnchors2D] aligned to the current grid. - */ - val handleAnchors = remember(scaledCellPixelSize, cellWindow) { - GridDraggableAnchors2d(scaledCellPixelSize, cellWindow) - } - - /** - * State holders for the value change confirmation lambdas. - * - * These are initialized with a placeholder method, since this depends on the state of the other handles. - */ - val confirmValueChangeStates = List(initialHandles.size) { index -> - key(index) { - remember { mutableStateOf({ _: IntOffset -> true }) } - } - } - - val coroutineScope = rememberCoroutineScope() - - /** - * A list of [Animatable]s for each handle representing the fractional part of the initial handle value, in - * cell coordinates. - * - * This will be initially added to the offset calculations, and animated to zero. - */ - val transientSelectingBoxAnimatables = initialHandles.mapIndexed { index, offset -> - key(index) { - remember { - Animatable( - initialValue = offset - offset.round().toOffset(), - typeConverter = Offset.VectorConverter, - visibilityThreshold = Offset.VisibilityThreshold / scaledCellPixelSize, - ) - } - } - } - - // Resolve the transient offsets to zero - transientSelectingBoxAnimatables.forEachIndexed { index, animatable -> - key(index) { - LaunchedEffect(animatable) { - animatable.animateTo(Offset.Zero) - } - } - } - - @Stable - class SelectionDraggableHandleState( - val state: HandleState, - transientSelectingBoxAnimatable: Animatable, - val horizontalPairState: HandleState, - val verticalPairState: HandleState, - val oppositeCornerState: HandleState, - ) { - val confirmValueChange: (IntOffset) -> Boolean = { intOffset -> - if (state.reentrancyCount == 0) { - val minX = min(intOffset.x, oppositeCornerState.state.targetValue.x) - val maxX = max(intOffset.x, oppositeCornerState.state.targetValue.x) - val minY = min(intOffset.y, oppositeCornerState.state.targetValue.y) - val maxY = max(intOffset.y, oppositeCornerState.state.targetValue.y) - - setSelectionState( - SelectionState.SelectingBox.FixedSelectingBox( - topLeft = IntOffset(minX, minY), - width = maxX - minX, - height = maxY - minY, - previousTransientSelectingBox = null, - ), - ) - - coroutineScope.launch { - try { - horizontalPairState.reentrancyCount++ - horizontalPairState.state.snapTo( - IntOffset( - x = horizontalPairState.state.targetValue.x, - y = intOffset.y, - ), - ) - } finally { - horizontalPairState.reentrancyCount-- - } - } - coroutineScope.launch { - try { - verticalPairState.reentrancyCount++ - verticalPairState.state.snapTo( - IntOffset( - x = intOffset.x, - y = verticalPairState.state.targetValue.y, - ), - ) - } finally { - verticalPairState.reentrancyCount-- - } - } - } - true - } - - val offsetCalculator: () -> Offset = { - val xReferenceState = when { - state.state.isDraggingOrAnimating() -> state - verticalPairState.state.isDraggingOrAnimating() -> verticalPairState - else -> state - } - val yReferenceState = when { - state.state.isDraggingOrAnimating() -> state - horizontalPairState.state.isDraggingOrAnimating() -> horizontalPairState - else -> state - } - - Offset( - xReferenceState.state.requireOffset().x, - yReferenceState.state.requireOffset().y, - ) + transientSelectingBoxAnimatable.value * scaledCellPixelSize - } - } - - val handleAnchoredDraggable2DStates = - initialHandles.mapIndexed { index, initialHandleOffset -> - key(index, scaledCellPixelSize, cellWindow) { - rememberSaveable( - saver = Saver( - save = { packInts(it.currentValue.x, it.currentValue.y) }, - restore = { - AnchoredDraggable2DState( - initialValue = IntOffset(unpackInt1(it), unpackInt2(it)), - animationSpec = spring(), - confirmValueChange = { intOffset -> - confirmValueChangeStates[index].value.invoke(intOffset) - }, - ) - }, - ), - ) { - AnchoredDraggable2DState( - initialValue = initialHandleOffset.round(), - animationSpec = spring(), - confirmValueChange = { intOffset -> - confirmValueChangeStates[index].value.invoke(intOffset) - }, - ) - }.apply { - updateAnchors( - newAnchors = handleAnchors, - newTarget = targetValue, - ) - } - } - } - - val handleStates = handleAnchoredDraggable2DStates.mapIndexed { index, handleAnchoredDraggable2DState -> - key(index) { - remember(handleAnchoredDraggable2DState) { - HandleState(handleAnchoredDraggable2DState) - } - } - } - - val handleAState = handleStates[0] - val handleBState = handleStates[1] - val handleCState = handleStates[2] - val handleDState = handleStates[3] - - val selectionHandleStates = remember(handleStates, transientSelectingBoxAnimatables) { - listOf( - SelectionDraggableHandleState( - state = handleAState, - transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[0], - horizontalPairState = handleBState, - verticalPairState = handleDState, - oppositeCornerState = handleCState, - ), - SelectionDraggableHandleState( - state = handleBState, - transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[1], - horizontalPairState = handleAState, - verticalPairState = handleCState, - oppositeCornerState = handleDState, - ), - SelectionDraggableHandleState( - state = handleCState, - transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[2], - horizontalPairState = handleDState, - verticalPairState = handleBState, - oppositeCornerState = handleAState, - ), - SelectionDraggableHandleState( - state = handleDState, - transientSelectingBoxAnimatable = transientSelectingBoxAnimatables[3], - horizontalPairState = handleCState, - verticalPairState = handleAState, - oppositeCornerState = handleBState, - ), - ) - } - - selectionHandleStates.forEachIndexed { index, selectionDraggableHandleState -> - confirmValueChangeStates[index].value = selectionDraggableHandleState.confirmValueChange - } - - SelectingBox( - modifier = Modifier - .fillMaxSize() - .boxLayoutByHandles( - handleAOffsetCalculator = selectionHandleStates[0].offsetCalculator, - handleBOffsetCalculator = selectionHandleStates[1].offsetCalculator, - handleCOffsetCalculator = selectionHandleStates[2].offsetCalculator, - handleDOffsetCalculator = selectionHandleStates[3].offsetCalculator, - ) - .cellStateDragAndDropSource(getSelectionCellState), - ) - - val parameterizedStringResolver = parameterizedStringResolver() - - selectionHandleStates - .forEachIndexed { index, selectionDraggableHandleState -> - key(index) { - val interactionSource = remember { MutableInteractionSource() } - - val isDragged by interactionSource.collectIsDraggedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - - val isActive = isDragged || isHovered || isPressed - - SelectionHandle( - isActive = isActive, - modifier = Modifier - .offset { - selectionDraggableHandleState.offsetCalculator().round() - } - .graphicsLayer { - translationX = -size.width / 2f - translationY = -size.height / 2f - } - .anchoredDraggable2D( - state = selectionDraggableHandleState.state.state, - interactionSource = interactionSource, - ) - .semantics { - val targetValue = selectionDraggableHandleState.state.state.targetValue - contentDescription = parameterizedStringResolver( - Strings.SelectingBoxHandle( - targetValue.x, - targetValue.y, - ), - ) - }, - ) - } - } - } -} - -@Stable -private class HandleState( - val state: AnchoredDraggable2DState, -) { - var reentrancyCount by mutableStateOf(0) -} - -@Composable -private fun TransientSelectingBoxOverlay( - selectionState: SelectionState.SelectingBox.TransientSelectingBox, - scaledCellPixelSize: Float, - cellWindow: CellWindow, - modifier: Modifier = Modifier, -) { - Box(modifier) { - val selectionRect = selectionState.rect.translate(-cellWindow.topLeft.toOffset()) - - val handleAOffsetCalculator = { selectionRect.topLeft * scaledCellPixelSize } - val handleBOffsetCalculator = { selectionRect.topRight * scaledCellPixelSize } - val handleCOffsetCalculator = { selectionRect.bottomRight * scaledCellPixelSize } - val handleDOffsetCalculator = { selectionRect.bottomLeft * scaledCellPixelSize } - - val handleOffsetCalculators = listOf( - handleAOffsetCalculator, - handleBOffsetCalculator, - handleCOffsetCalculator, - handleDOffsetCalculator, - ) - - SelectingBox( - modifier = Modifier - .fillMaxSize() - .boxLayoutByHandles( - handleAOffsetCalculator = { - selectionRect.topLeft * scaledCellPixelSize - }, - handleBOffsetCalculator = { - selectionRect.topRight * scaledCellPixelSize - }, - handleCOffsetCalculator = { - selectionRect.bottomRight * scaledCellPixelSize - }, - handleDOffsetCalculator = { - selectionRect.bottomLeft * scaledCellPixelSize - }, - ), - ) - - handleOffsetCalculators.mapIndexed { index, offsetCalculator -> - key(index) { - SelectionHandle( - isActive = index == 2, - modifier = Modifier - .offset { - offsetCalculator().round() - } - .graphicsLayer { - translationX = -size.width / 2f - translationY = -size.height / 2f - }, - ) - } - } - } -} - -/** - * A selection handle. - */ -@Composable -fun SelectionHandle( - isActive: Boolean, - modifier: Modifier = Modifier, - selectionColor: Color = MaterialTheme.colorScheme.secondary, -) { - Box( - modifier = modifier.size(48.dp), - contentAlignment = Alignment.Center, - ) { - val size by animateDpAsState(if (isActive) 24.dp else 16.dp) - val elevation by animateDpAsState(if (isActive) 4.dp else 0.dp) - Surface( - modifier = Modifier.size(size), - shape = CircleShape, - shadowElevation = elevation, - color = selectionColor, - ) {} - } -} - /** * A custom [layout] modifier that measures a box bounded by the given 4 offsets, such that the box doesn't extend * beyond the bounds of the parent. @@ -727,198 +276,5 @@ fun Modifier.boxLayoutByHandles( } } -/** - * The selecting box itself. - */ -@Composable -fun SelectingBox( - // noinspection ComposeModifierWithoutDefault - modifier: Modifier, - selectionColor: Color = MaterialTheme.colorScheme.secondary, -) { - Canvas( - modifier = modifier.fillMaxSize(), - ) { - drawRect( - color = selectionColor, - alpha = 0.2f, - ) - drawDashedRect( - selectionColor = selectionColor, - strokeWidth = 2.dp.toPx(), - intervals = floatArrayOf( - 24.dp.toPx(), - 24.dp.toPx(), - ), - phase = 12.dp.toPx(), - ) - } -} - -/** - * Draws a dashed line rectangle, with intervals and phase applied symmetrically. - * - * The dashed line effect is drawn in 8 line segments: from the middle of each side, - * to each corner. - */ -private fun DrawScope.drawDashedRect( - selectionColor: Color, - strokeWidth: Float, - intervals: FloatArray, - phase: Float = 0f, - rect: Rect = size.toRect(), -) { - // Draw the selection outlines in a way that is symmetric, and hides the extra of the lines near the - // corners with the selection handles - listOf( - rect.topCenter to rect.topLeft, - rect.topCenter to rect.topRight, - rect.centerLeft to rect.topLeft, - rect.centerLeft to rect.bottomLeft, - rect.bottomCenter to rect.bottomLeft, - rect.bottomCenter to rect.bottomRight, - rect.centerRight to rect.topRight, - rect.centerRight to rect.bottomRight, - ).forEach { (start, end) -> - drawDashedLine( - color = selectionColor, - start = start, - end = end, - strokeWidth = strokeWidth, - intervals = intervals, - phase = phase, - ) - } -} - -@Suppress("LongParameterList") -internal expect fun DrawScope.drawDashedLine( - color: Color, - start: Offset, - end: Offset, - intervals: FloatArray, - phase: Float = 0f, - strokeWidth: Float = Stroke.HairlineWidth, - cap: StrokeCap = Stroke.DefaultCap, - @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, - colorFilter: ColorFilter? = null, - blendMode: BlendMode = DefaultBlendMode, -) - -data class GridDraggableAnchors2d( - private val scaledCellPixelSize: Float, - private val cellWindow: CellWindow, -) : DraggableAnchors2D { - override fun positionOf(anchor: IntOffset): Offset = - (anchor.toOffset() - cellWindow.topLeft.toOffset()) * scaledCellPixelSize - - override fun hasPositionFor(anchor: IntOffset): Boolean = true - override fun closestAnchor(position: Offset): IntOffset = - (position / scaledCellPixelSize).round() + cellWindow.topLeft - - override val size: Int = Int.MAX_VALUE -} - -@Suppress("LongParameterList", "LongMethod") -@Composable -private fun SelectionBoxOverlay( - selectionSessionState: SessionValue, - setSelectionState: (SelectionState) -> Unit, - getSelectionCellState: () -> CellState, - scaledCellPixelSize: Float, - cellWindow: CellWindow, - modifier: Modifier = Modifier, -) { - val handleAnchors = remember(scaledCellPixelSize, cellWindow) { - GridDraggableAnchors2d(scaledCellPixelSize, cellWindow) - } - - val initialOffset = selectionSessionState.value.offset - - val currentSelectionSessionState by rememberUpdatedState(selectionSessionState) - val currentSetSelectionState by rememberUpdatedState(setSelectionState) - - val confirmValueChange = { intOffset: IntOffset -> - setSelectionState( - currentSelectionSessionState.value.copy( - offset = intOffset, - ), - ) - true - } - - val draggable2DState = rememberSaveable( - saver = Saver( - save = { packInts(it.currentValue.x, it.currentValue.y) }, - restore = { - AnchoredDraggable2DState( - initialValue = IntOffset(unpackInt1(it), unpackInt2(it)), - animationSpec = spring(), - confirmValueChange = confirmValueChange, - ) - }, - ), - ) { - AnchoredDraggable2DState( - initialValue = initialOffset, - animationSpec = spring(), - confirmValueChange = confirmValueChange, - ) - }.apply { - updateAnchors( - newAnchors = handleAnchors, - // Ensure the target value remains the same due to updating the anchors. - // This keeps the selection box stationary relative to the overall universe. - newTarget = targetValue, - ) - } - - // As a side-effect of dragging, update the underlying selection state to match the intermediate selection. - LaunchedEffect(draggable2DState) { - snapshotFlow { draggable2DState.currentValue } - .onEach { intOffset -> - currentSetSelectionState( - currentSelectionSessionState.value.copy( - offset = intOffset, - ), - ) - } - .collect() - } - - val boundingBox = selectionSessionState.value.cellState.boundingBox - - SelectingBox( - modifier = modifier - .fillMaxSize() - .boxLayoutByHandles( - handleAOffsetCalculator = { - draggable2DState.requireOffset() - }, - handleBOffsetCalculator = { - draggable2DState.requireOffset() + Offset( - boundingBox.width.toFloat(), - 0f, - ) * scaledCellPixelSize - }, - handleCOffsetCalculator = { - draggable2DState.requireOffset() + Offset( - boundingBox.width.toFloat(), - boundingBox.height.toFloat(), - ) * scaledCellPixelSize - }, - handleDOffsetCalculator = { - draggable2DState.requireOffset() + Offset( - 0f, - boundingBox.height.toFloat(), - ) * scaledCellPixelSize - }, - ) - .cellStateDragAndDropSource(getSelectionCellState) - .anchoredDraggable2D(draggable2DState), - selectionColor = MaterialTheme.colorScheme.tertiary, - ) -} - fun AnchoredDraggable2DState.isDraggingOrAnimating(): Boolean = anchors.positionOf(currentValue) != requireOffset() diff --git a/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/TransientSelectingBoxOverlay.kt b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/TransientSelectingBoxOverlay.kt new file mode 100644 index 0000000000..997a4e34eb --- /dev/null +++ b/ui-cells/src/jbMain/kotlin/com/alexvanyo/composelife/ui/cells/TransientSelectingBoxOverlay.kt @@ -0,0 +1,87 @@ +/* + * 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 + * + * https://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 com.alexvanyo.composelife.ui.cells + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toOffset +import com.alexvanyo.composelife.model.CellWindow + +@Composable +internal fun TransientSelectingBoxOverlay( + selectionState: SelectionState.SelectingBox.TransientSelectingBox, + scaledCellPixelSize: Float, + cellWindow: CellWindow, + modifier: Modifier = Modifier, +) { + Box(modifier) { + val selectionRect = selectionState.rect.translate(-cellWindow.topLeft.toOffset()) + + val handleAOffsetCalculator = { selectionRect.topLeft * scaledCellPixelSize } + val handleBOffsetCalculator = { selectionRect.topRight * scaledCellPixelSize } + val handleCOffsetCalculator = { selectionRect.bottomRight * scaledCellPixelSize } + val handleDOffsetCalculator = { selectionRect.bottomLeft * scaledCellPixelSize } + + val handleOffsetCalculators = listOf( + handleAOffsetCalculator, + handleBOffsetCalculator, + handleCOffsetCalculator, + handleDOffsetCalculator, + ) + + SelectingBox( + modifier = Modifier + .fillMaxSize() + .boxLayoutByHandles( + handleAOffsetCalculator = { + selectionRect.topLeft * scaledCellPixelSize + }, + handleBOffsetCalculator = { + selectionRect.topRight * scaledCellPixelSize + }, + handleCOffsetCalculator = { + selectionRect.bottomRight * scaledCellPixelSize + }, + handleDOffsetCalculator = { + selectionRect.bottomLeft * scaledCellPixelSize + }, + ), + ) + + handleOffsetCalculators.mapIndexed { index, offsetCalculator -> + key(index) { + SelectionHandle( + isActive = index == 2, + modifier = Modifier + .offset { + offsetCalculator().round() + } + .graphicsLayer { + translationX = -size.width / 2f + translationY = -size.height / 2f + }, + ) + } + } + } +} diff --git a/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/InteractableCellsTests.kt b/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/InteractableCellsTests.kt index 27d1f44bca..d15d82cc64 100644 --- a/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/InteractableCellsTests.kt +++ b/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/InteractableCellsTests.kt @@ -16,6 +16,7 @@ package com.alexvanyo.composelife.ui.cells +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsOff @@ -87,6 +88,7 @@ class InteractableCellsTests { IntSize(9, 9), ), ), + pixelOffsetFromCenter = Offset.Zero, ) } } @@ -189,6 +191,7 @@ class InteractableCellsTests { IntSize(9, 9), ), ), + pixelOffsetFromCenter = Offset.Zero, ) } } @@ -251,6 +254,7 @@ class InteractableCellsTests { IntSize(9, 9), ), ), + pixelOffsetFromCenter = Offset.Zero, ) } } @@ -318,6 +322,7 @@ class InteractableCellsTests { IntSize(9, 9), ), ), + pixelOffsetFromCenter = Offset.Zero, ) } } diff --git a/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlayTests.kt b/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlayTests.kt index 60d38ed9a9..b27b231a09 100644 --- a/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlayTests.kt +++ b/ui-cells/src/jbTest/kotlin/com/alexvanyo/composelife/ui/cells/SelectionOverlayTests.kt @@ -16,6 +16,7 @@ package com.alexvanyo.composelife.ui.cells +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.dragAndDrop @@ -26,19 +27,19 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.alexvanyo.composelife.model.di.CellStateParserProvider import com.alexvanyo.composelife.geometry.toPx import com.alexvanyo.composelife.kmpandroidrunner.KmpAndroidJUnit4 import com.alexvanyo.composelife.model.CellWindow +import com.alexvanyo.composelife.model.di.CellStateParserProvider import com.alexvanyo.composelife.model.emptyCellState import com.alexvanyo.composelife.parameterizedstring.ParameterizedString import com.alexvanyo.composelife.parameterizedstring.parameterizedStringResolver import com.alexvanyo.composelife.sessionvalue.SessionValue import com.alexvanyo.composelife.test.BaseUiInjectTest import com.alexvanyo.composelife.test.runUiTest -import com.alexvanyo.composelife.ui.cells.util.isAndroid import com.alexvanyo.composelife.ui.cells.resources.SelectingBoxHandle import com.alexvanyo.composelife.ui.cells.resources.Strings +import com.alexvanyo.composelife.ui.cells.util.isAndroid import org.junit.Assume.assumeTrue import org.junit.runner.RunWith import kotlin.test.Test @@ -77,6 +78,7 @@ class SelectionOverlayTests : BaseUiInjectTest