Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package ge.yet3.blokblast.component.utils

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.animation.Direction
import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation
import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimator
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.PredictiveBackAnimatable
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimator
import com.arkivanov.essenty.backhandler.BackEvent
import com.arkivanov.essenty.backhandler.BackHandler

/**
* iOS-style predictive back animation.
*
* Replacement for Decompose 3.5.0's default [predictiveBackAnimation], which
* crashes on cancelled back gestures with
* `IllegalArgumentException: Corner size in Px can't be negative`.
*
* The default animatable animates a RoundedCornerShape's corner radius via a
* spring that overshoots below 0 on cancel, then hands that negative value to
* RoundedCornerShape.createOutline which throws. This implementation uses only
* translation (and an alpha-coerced dim layer) — no animated shape, so any
* spring overshoot is harmless.
*/
@OptIn(ExperimentalDecomposeApi::class)
@Composable
fun <C : Any, T : Any> cupertinoPredictiveBackAnimation(
backHandler: BackHandler,
onBack: () -> Unit,
fallbackAnimation: StackAnimation<C, T>? = stackAnimation(
animator = cupertinoStackAnimator(),
disableInputDuringAnimation = true,
),
): StackAnimation<C, T> = predictiveBackAnimation(
backHandler = backHandler,
fallbackAnimation = fallbackAnimation,
onBack = onBack,
selector = { initialBackEvent, _, _ ->
cupertinoPredictiveBackAnimatable(initialBackEvent = initialBackEvent)
},
)

@ExperimentalDecomposeApi
private fun cupertinoPredictiveBackAnimatable(
initialBackEvent: BackEvent,
): PredictiveBackAnimatable = CupertinoPredictiveBackAnimatable(initialBackEvent)

@OptIn(ExperimentalDecomposeApi::class)
private class CupertinoPredictiveBackAnimatable(
initialBackEvent: BackEvent,
) : PredictiveBackAnimatable {

private val progressAnimatable = Animatable(initialValue = initialBackEvent.progress)

@Suppress("unused")
private var swipeEdge by mutableStateOf(initialBackEvent.swipeEdge)

override val exitModifier: Modifier = Modifier
.cupertinoPredictiveExit { progressAnimatable.value }

override val enterModifier: Modifier
get() = Modifier.cupertinoPredictiveEnter { progressAnimatable.value }

override suspend fun animate(event: BackEvent) {
swipeEdge = event.swipeEdge
progressAnimatable.snapTo(targetValue = event.progress)
}

override suspend fun cancel() {
progressAnimatable.animateTo(targetValue = 0f)
}

override suspend fun finish() {
progressAnimatable.animateTo(targetValue = 1f)
}
}

private fun cupertinoStackAnimator(
animationSpec: FiniteAnimationSpec<Float> = cupertinoTween(durationMillis = 500),
): StackAnimator = stackAnimator(
animationSpec = animationSpec,
) { factor, direction, content ->
content(
Modifier.composed {
val layoutDirection = LocalLayoutDirection.current
graphicsLayer {
translationX = size.width * when (direction) {
Direction.ENTER_FRONT,
Direction.EXIT_FRONT -> factor

else -> factor * SlideFactor
}
if (layoutDirection == LayoutDirection.Rtl) {
translationX = -translationX
}
}
},
)
}

private fun Modifier.cupertinoPredictiveEnter(progress: () -> Float): Modifier = composed {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
graphicsLayer {
translationX = (progress() - 1f) * SlideFactor * size.width
if (isRtl) translationX = -translationX
}.drawWithContent {
drawContent()
drawRect(Color.Black, alpha = ((1f - progress()) * SlideFactor).coerceIn(0f, 1f))
}
}

private fun Modifier.cupertinoPredictiveExit(progress: () -> Float): Modifier = composed {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
graphicsLayer {
translationX = progress() * size.width
if (isRtl) translationX = -translationX
}
}

private const val SlideFactor = 0.25f

private fun <T> cupertinoTween(
durationMillis: Int = CupertinoTransitionDuration,
delayMillis: Int = 0,
easing: Easing = CupertinoEasing,
): TweenSpec<T> = tween(
durationMillis = durationMillis,
easing = easing,
delayMillis = delayMillis,
)

private val CupertinoEasing = CubicBezierEasing(0.2833f, 0.99f, 0.31833f, 0.99f)
private const val CupertinoTransitionDuration = 400
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,12 @@ class DragDropState {
val ghostH = piece.shape.height * ghostCellSizePx +
(piece.shape.height - 1).coerceAtLeast(0) * ghostGapPx

val ghostTopLeftX = dragPosition.x - fingerOffset.x - ghostW / 2f
val ghostTopLeftY = dragPosition.y - fingerOffset.y - ghostH - verticalLiftPx
// The floating ghost is drawn with its center at [dragPosition] (virtual
// finger) horizontally, and entirely above the finger vertically.
// This is a "lifted" drag style that ensures the piece is never
// obscured by the user's thumb.
val ghostTopLeftX = dragPosition.x - ghostW / 2f
val ghostTopLeftY = dragPosition.y - ghostH - verticalLiftPx

// Snap by rounding the ghost's top-left to the nearest grid cell
// — rounding (not floor) so half-cell overlaps jump to the closer
Expand All @@ -96,6 +100,10 @@ class DragDropState {
val anchorX = kotlin.math.round(relX / step).toInt()
val anchorY = kotlin.math.round(relY / step).toInt()

// Always recompute validity — the grid can change under a stationary
// finger (line-clear animation finishes mid-drag and frees cells).
// mutableStateOf's structural equality elides redundant emits, so
// there's no need to gate the writes manually.
hoverAnchor = anchorX to anchorY
isValidPlacement = canPlacePiece(piece.shape, anchorX, anchorY, grid)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,7 @@ fun DraggedPieceOverlay(
cellSize: Dp,
gap: Dp,
verticalLift: Dp,
dragPositionX: Float,
dragPositionY: Float,
fingerOffsetX: Float,
fingerOffsetY: Float,
dragDropState: DragDropState,
modifier: Modifier = Modifier,
) {
val shadowCells = remember(piece) {
Expand All @@ -46,13 +43,14 @@ fun DraggedPieceOverlay(
Box(
modifier = modifier
.offset {
val dragPosition = dragDropState.dragPosition
val ghostW = cellSize.toPx() * piece.shape.width +
gap.toPx() * (piece.shape.width - 1).coerceAtLeast(0)
val ghostH = cellSize.toPx() * piece.shape.height +
gap.toPx() * (piece.shape.height - 1).coerceAtLeast(0)
IntOffset(
x = (dragPositionX - fingerOffsetX - ghostW / 2f).toInt(),
y = (dragPositionY - fingerOffsetY - ghostH - verticalLift.toPx()).toInt(),
x = (dragPosition.x - ghostW / 2f).toInt(),
y = (dragPosition.y - ghostH - verticalLift.toPx()).toInt(),
)
}
.graphicsLayer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import ge.yet3.blokblast.component.overlay.rememberGameTutorialSteps
import ge.yet3.blokblast.theme.LocalOnTutorialSeen
import ge.yet3.blokblast.theme.LocalTutorialSeen
import ge.yet3.blokblast.theme.LocalVibrationEnabled
import ge.yet.blokblast.domain.model.Grid
import androidx.compose.ui.unit.dp
import blockblast.composeapp.generated.resources.Res
import blockblast.composeapp.generated.resources.best
Expand Down Expand Up @@ -183,8 +184,8 @@ fun GameContent(component: GameComponent) {
LaunchedEffect(model.lastPointsAwarded) {
val points = model.lastPointsAwarded.points
if (points > 0) {
val cx = gridOriginX + (8 * cellSizePx + 7 * gapPx) / 2f
val cy = gridOriginY + (8 * cellSizePx + 7 * gapPx) / 2f
val cx = gridOriginX + (Grid.SIZE * cellSizePx + (Grid.SIZE - 1) * gapPx) / 2f
val cy = gridOriginY + (Grid.SIZE * cellSizePx + (Grid.SIZE - 1) * gapPx) / 2f
floatingScore.add(points, androidx.compose.ui.geometry.Offset(cx, cy))
}
}
Expand Down Expand Up @@ -414,10 +415,7 @@ fun GameContent(component: GameComponent) {
cellSize = DRAG_GHOST_CELL_SIZE,
gap = DRAG_GHOST_GAP,
verticalLift = DRAG_GHOST_VERTICAL_LIFT,
dragPositionX = dragDrop.dragPosition.x,
dragPositionY = dragDrop.dragPosition.y,
fingerOffsetX = dragDrop.fingerOffset.x,
fingerOffsetY = dragDrop.fingerOffset.y,
dragDropState = dragDrop,
)
}

Expand Down
Loading
Loading