Last active
January 20, 2026 13:21
-
-
Save Kyriakos-Georgiopoulos/ef1b7e26ca9d2a34d4ee45f794e2e7d8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright 2026 Kyriakos Georgiopoulos | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import androidx.activity.compose.BackHandler | |
| import androidx.annotation.DrawableRes | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.CubicBezierEasing | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.Image | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.gestures.scrollBy | |
| import androidx.compose.foundation.interaction.MutableInteractionSource | |
| import androidx.compose.foundation.interaction.collectIsPressedAsState | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.PaddingValues | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.offset | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.statusBarsPadding | |
| import androidx.compose.foundation.lazy.LazyRow | |
| import androidx.compose.foundation.lazy.rememberLazyListState | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.rememberUpdatedState | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.runtime.snapshotFlow | |
| import androidx.compose.runtime.withFrameNanos | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.layout.ContentScale | |
| import androidx.compose.ui.layout.LayoutCoordinates | |
| import androidx.compose.ui.layout.boundsInRoot | |
| import androidx.compose.ui.layout.onGloballyPositioned | |
| import androidx.compose.ui.layout.onSizeChanged | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.res.painterResource | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.IntOffset | |
| import androidx.compose.ui.unit.IntSize | |
| import androidx.compose.ui.unit.dp | |
| import com.zengrip.R | |
| import kotlinx.coroutines.delay | |
| import kotlinx.coroutines.flow.distinctUntilChanged | |
| import kotlinx.coroutines.flow.filter | |
| import kotlinx.coroutines.flow.first | |
| import kotlinx.coroutines.isActive | |
| import kotlinx.coroutines.launch | |
| import kotlin.math.max | |
| import kotlin.math.roundToInt | |
| /** | |
| * Displays multiple horizontally auto-scrolling rows of image cards. | |
| * | |
| * Tapping a card triggers a two-step transition: | |
| * 1) Non-selected cards scatter off-screen. | |
| * 2) The selected card animates into a centered square "hero" overlay. | |
| * | |
| * The hero overlay preserves the card corner radius and fades its white background to transparent | |
| * on expand, then back to white on collapse. | |
| */ | |
| @Composable | |
| fun AutoScrollRows( | |
| modifier: Modifier = Modifier, | |
| rowCount: Int = 3, | |
| itemCountPerRow: Int = 40, | |
| itemSize: Dp = 250.dp, | |
| itemCornerRadius: Dp = 30.dp, | |
| rowSpacing: Dp = 26.dp, | |
| itemSpacing: Dp = 18.dp, | |
| horizontalPadding: Dp = 18.dp, | |
| ) { | |
| val images: List<Int> = remember { | |
| listOf( | |
| R.drawable.android_1, | |
| R.drawable.android_2, | |
| R.drawable.android_3, | |
| R.drawable.android_4, | |
| R.drawable.android_5, | |
| R.drawable.android_6, | |
| R.drawable.android_7, | |
| R.drawable.android_8, | |
| R.drawable.android_9, | |
| R.drawable.android_10, | |
| R.drawable.android_11, | |
| R.drawable.android_12, | |
| R.drawable.android_13, | |
| R.drawable.android_14, | |
| R.drawable.android_15, | |
| R.drawable.android_16, | |
| R.drawable.android_17, | |
| R.drawable.android_18, | |
| R.drawable.android_19, | |
| R.drawable.android_20, | |
| R.drawable.android_21, | |
| R.drawable.android_22, | |
| R.drawable.android_23, | |
| ) | |
| } | |
| val rows = remember(rowCount, itemCountPerRow, images) { | |
| List(rowCount) { rowIndex -> | |
| val scrollToLeft = rowIndex % 2 == 0 | |
| val items = List(itemCountPerRow) { itemIndex -> | |
| val imageRes = images[(rowIndex * 7 + itemIndex) % max(images.size, 1)] | |
| RowItem(id = "R$rowIndex-$itemIndex", imageRes = imageRes) | |
| } | |
| RowSpec( | |
| id = rowIndex, | |
| scrollToLeft = scrollToLeft, | |
| speedPxPerSec = 95f + rowIndex * 18f, | |
| items = items | |
| ) | |
| } | |
| } | |
| val background = Color(0xFFF1EEE7) | |
| var selected by remember { mutableStateOf<SelectedItem?>(null) } | |
| val scope = rememberCoroutineScope() | |
| val rowVisibility = remember(rowCount) { List(rowCount) { Animatable(0f) } } | |
| val scatterProgress = remember { Animatable(0f) } | |
| val heroProgress = remember { Animatable(0f) } | |
| var rootSize by remember { mutableStateOf(IntSize.Zero) } | |
| var rootBoundsInRoot by remember { mutableStateOf<Rect?>(null) } | |
| LaunchedEffect(rowCount) { | |
| rowVisibility.forEachIndexed { index, anim -> | |
| launch { | |
| delay(110L * index) | |
| anim.snapTo(0f) | |
| anim.animateTo( | |
| 1f, | |
| animationSpec = tween( | |
| durationMillis = 760, | |
| easing = CubicBezierEasing(0.16f, 1f, 0.3f, 1f) | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| val scatterSpecOut = tween<Float>( | |
| durationMillis = 980, | |
| easing = CubicBezierEasing(0.22f, 0f, 0.18f, 1f) | |
| ) | |
| val scatterSpecIn = tween<Float>( | |
| durationMillis = 980, | |
| easing = CubicBezierEasing(0.22f, 0f, 0.18f, 1f) | |
| ) | |
| val heroSpecOpen = spring<Float>( | |
| dampingRatio = 0.75f, | |
| stiffness = Spring.StiffnessMediumLow, | |
| visibilityThreshold = 0.001f | |
| ) | |
| val heroSpecClose = spring<Float>( | |
| dampingRatio = 0.55f, | |
| stiffness = Spring.StiffnessMediumLow, | |
| visibilityThreshold = 0.001f | |
| ) | |
| fun showDetail(item: RowItem, instanceKey: String, itemBoundsInRoot: Rect) { | |
| scope.launch { | |
| val rb = rootBoundsInRoot ?: return@launch | |
| val localStartBounds = itemBoundsInRoot.offsetBy(-rb.left, -rb.top) | |
| selected = | |
| SelectedItem(item = item, startBounds = localStartBounds, instanceKey = instanceKey) | |
| scatterProgress.snapTo(0f) | |
| heroProgress.snapTo(0f) | |
| withFrameNanos { } | |
| scatterProgress.animateTo(1f, animationSpec = scatterSpecOut) | |
| heroProgress.animateTo(1f, animationSpec = heroSpecOpen) | |
| } | |
| } | |
| fun hideDetail() { | |
| scope.launch { | |
| heroProgress.animateTo(0f, animationSpec = heroSpecClose) | |
| scatterProgress.animateTo(0f, animationSpec = scatterSpecIn) | |
| heroProgress.snapTo(0f) | |
| selected = null | |
| } | |
| } | |
| BackHandler(enabled = selected != null) { hideDetail() } | |
| val freezeScroll = selected != null || | |
| scatterProgress.value > 0.01f || | |
| heroProgress.value > 0.01f | |
| Box( | |
| modifier = modifier | |
| .fillMaxSize() | |
| .background(background) | |
| .statusBarsPadding() | |
| .onSizeChanged { rootSize = it } | |
| .onGloballyPositioned { rootBoundsInRoot = it.boundsInRoot() } | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(top = 12.dp), | |
| verticalArrangement = Arrangement.spacedBy(rowSpacing) | |
| ) { | |
| rows.forEachIndexed { index, row -> | |
| SeamlessRow( | |
| rowId = row.id, | |
| items = row.items, | |
| scrollToLeft = row.scrollToLeft, | |
| speedPxPerSec = row.speedPxPerSec, | |
| height = itemSize, | |
| horizontalPadding = horizontalPadding, | |
| itemSpacing = itemSpacing, | |
| itemSize = itemSize, | |
| itemCornerRadius = itemCornerRadius, | |
| visibility = rowVisibility[index].value, | |
| scrollEnabled = !freezeScroll && rowVisibility[index].value > 0.02f, | |
| hiddenInstanceKey = selected?.instanceKey, | |
| rootSize = rootSize, | |
| scatter = scatterProgress.value, | |
| selectedInstanceKey = selected?.instanceKey, | |
| onItemTapped = { tapped, instanceKey, boundsInRoot -> | |
| if (selected != null) return@SeamlessRow | |
| showDetail(tapped, instanceKey, boundsInRoot) | |
| } | |
| ) | |
| } | |
| } | |
| val sel = selected | |
| if (sel != null && rootSize.width > 0 && rootSize.height > 0) { | |
| HeroOverlayFullscreenBounds( | |
| selected = sel, | |
| progress = heroProgress.value, | |
| rootSize = rootSize, | |
| initialCornerRadius = itemCornerRadius, | |
| background = background, | |
| onBackdropClick = { hideDetail() } | |
| ) | |
| } | |
| } | |
| } | |
| private data class RowSpec( | |
| val id: Int, | |
| val scrollToLeft: Boolean, | |
| val speedPxPerSec: Float, | |
| val items: List<RowItem>, | |
| ) | |
| private data class RowItem( | |
| val id: String, | |
| @DrawableRes val imageRes: Int? | |
| ) | |
| private data class SelectedItem( | |
| val item: RowItem, | |
| val startBounds: Rect, | |
| val instanceKey: String | |
| ) | |
| /** | |
| * A horizontally scrolling row that renders an effectively infinite list by repeating [items]. | |
| * The row auto-scrolls while enabled and uses [visibility] to animate its entrance. | |
| */ | |
| @Composable | |
| private fun SeamlessRow( | |
| rowId: Int, | |
| items: List<RowItem>, | |
| scrollToLeft: Boolean, | |
| speedPxPerSec: Float, | |
| height: Dp, | |
| horizontalPadding: Dp, | |
| itemSpacing: Dp, | |
| itemSize: Dp, | |
| itemCornerRadius: Dp, | |
| visibility: Float, | |
| scrollEnabled: Boolean, | |
| hiddenInstanceKey: String?, | |
| rootSize: IntSize, | |
| scatter: Float, | |
| selectedInstanceKey: String?, | |
| onItemTapped: (RowItem, String, Rect) -> Unit | |
| ) { | |
| val listState = rememberLazyListState() | |
| var widthPx by remember(rowId) { mutableStateOf(0f) } | |
| val startTx = remember(scrollToLeft, widthPx) { if (scrollToLeft) +widthPx else -widthPx } | |
| val v = visibility.coerceIn(0f, 1f) | |
| val tx = lerp(startTx, 0f, easeOutQuint(v)) | |
| val alpha = easeOutCubic(v) | |
| val scale = lerp(0.985f, 1f, easeOutQuint(v)) | |
| val latestVisibility by rememberUpdatedState(visibility) | |
| val latestScrollEnabled by rememberUpdatedState(scrollEnabled) | |
| val baseCount = items.size.coerceAtLeast(1) | |
| val mid = Int.MAX_VALUE / 2 | |
| val startIndex = remember(rowId, baseCount) { mid - (mid % baseCount) } | |
| var didCenter by remember(rowId) { mutableStateOf(false) } | |
| LaunchedEffect(rowId) { | |
| snapshotFlow { widthPx } | |
| .distinctUntilChanged() | |
| .filter { it > 0f } | |
| .first() | |
| if (!didCenter && items.isNotEmpty()) { | |
| didCenter = true | |
| listState.scrollToItem(startIndex) | |
| } | |
| } | |
| LaunchedEffect(rowId) { | |
| val scrollSign = if (scrollToLeft) +1f else -1f | |
| var lastNanos = 0L | |
| while (isActive) { | |
| val now = withFrameNanos { it } | |
| if (lastNanos == 0L) { | |
| lastNanos = now | |
| continue | |
| } | |
| val dtSec = (now - lastNanos) / 1_000_000_000f | |
| lastNanos = now | |
| val motion = if (latestScrollEnabled) latestVisibility.coerceIn(0f, 1f) else 0f | |
| val deltaPx = scrollSign * speedPxPerSec * motion * dtSec | |
| if (deltaPx != 0f) listState.scrollBy(deltaPx) | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(height) | |
| .onSizeChanged { widthPx = it.width.toFloat() }, | |
| contentAlignment = Alignment.CenterStart | |
| ) { | |
| LazyRow( | |
| state = listState, | |
| modifier = Modifier | |
| .graphicsLayer { | |
| translationX = tx | |
| this.alpha = alpha | |
| scaleX = scale | |
| scaleY = scale | |
| } | |
| .fillMaxSize(), | |
| horizontalArrangement = Arrangement.spacedBy(itemSpacing), | |
| contentPadding = PaddingValues(horizontal = horizontalPadding), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| items( | |
| count = Int.MAX_VALUE, | |
| key = { absoluteIndex -> "row$rowId-$absoluteIndex" } | |
| ) { absoluteIndex -> | |
| if (items.isEmpty()) return@items | |
| val item = items[absoluteIndex % items.size] | |
| val instanceKey = "row$rowId-$absoluteIndex" | |
| val hidden = hiddenInstanceKey != null && instanceKey == hiddenInstanceKey | |
| MediaBoxScatter( | |
| item = item, | |
| instanceKey = instanceKey, | |
| size = itemSize, | |
| cornerRadius = itemCornerRadius, | |
| hidden = hidden, | |
| rootSize = rootSize, | |
| scatter = scatter, | |
| selectedInstanceKey = selectedInstanceKey, | |
| onTapped = onItemTapped | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Renders an image card that can "scatter" off-screen based on [scatter]. | |
| * The card fades its white background to transparent while pressed. | |
| */ | |
| @Composable | |
| private fun MediaBoxScatter( | |
| item: RowItem, | |
| instanceKey: String, | |
| size: Dp, | |
| cornerRadius: Dp, | |
| hidden: Boolean, | |
| rootSize: IntSize, | |
| scatter: Float, | |
| selectedInstanceKey: String?, | |
| onTapped: (RowItem, String, Rect) -> Unit | |
| ) { | |
| val shape = RoundedCornerShape(cornerRadius) | |
| var coords by remember { mutableStateOf<LayoutCoordinates?>(null) } | |
| val interactionSource = remember { MutableInteractionSource() } | |
| val pressed by interactionSource.collectIsPressedAsState() | |
| var lastBounds by remember { mutableStateOf<Rect?>(null) } | |
| var lastCenter by remember { mutableStateOf(Offset.Zero) } | |
| val p = scatter.coerceIn(0f, 1f) | |
| val moveT = easeInOutCubic(p) | |
| val centerX = lastCenter.x | |
| val centerY = lastCenter.y | |
| val w = rootSize.width.toFloat().coerceAtLeast(1f) | |
| val h = rootSize.height.toFloat().coerceAtLeast(1f) | |
| val leftDist = centerX | |
| val rightDist = w - centerX | |
| val topDist = centerY | |
| val bottomDist = h - centerY | |
| val minDist = minOf(leftDist, rightDist, topDist, bottomDist) | |
| val itemW = (lastBounds?.width ?: 0f).coerceAtLeast(1f) | |
| val extra = (itemW * 1.65f) + 220f | |
| val (dx, dy) = when (minDist) { | |
| leftDist -> (-(centerX + extra)) to 0f | |
| rightDist -> (rightDist + extra) to 0f | |
| topDist -> 0f to (-(centerY + extra)) | |
| else -> 0f to (bottomDist + extra) | |
| } | |
| val isSelected = selectedInstanceKey != null && instanceKey == selectedInstanceKey | |
| val alphaT = (easeInOutCubic(p) * 0.97f).coerceIn(0f, 1f) | |
| val flyAlpha = if (isSelected) 1f else lerp(1f, 0f, alphaT) | |
| val flyScale = if (isSelected) 1f else lerp(1f, 0.96f, moveT) | |
| val bgAlpha by animateFloatAsState( | |
| targetValue = if (pressed) 0f else 1f, | |
| animationSpec = tween( | |
| durationMillis = 220, | |
| easing = CubicBezierEasing(0.22f, 0f, 0.18f, 1f) | |
| ), | |
| label = "cardBgAlpha" | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .size(size) | |
| .onGloballyPositioned { | |
| coords = it | |
| val bounds = it.boundsInRoot() | |
| lastBounds = bounds | |
| lastCenter = bounds.center | |
| } | |
| .graphicsLayer { | |
| alpha = if (hidden) 0f else flyAlpha | |
| if (!isSelected && lastBounds != null) { | |
| translationX = dx * moveT | |
| translationY = dy * moveT | |
| scaleX = flyScale | |
| scaleY = flyScale | |
| } | |
| } | |
| .clip(shape) | |
| .background(Color.White.copy(alpha = bgAlpha)) | |
| .clickable( | |
| enabled = !hidden, | |
| indication = null, | |
| interactionSource = interactionSource | |
| ) { | |
| val bounds = coords?.boundsInRoot() ?: lastBounds | |
| if (bounds != null) onTapped(item, instanceKey, bounds) | |
| } | |
| ) { | |
| item.imageRes?.let { res -> | |
| Image( | |
| painter = painterResource(res), | |
| contentDescription = null, | |
| modifier = Modifier.fillMaxSize(), | |
| contentScale = ContentScale.Crop | |
| ) | |
| } | |
| } | |
| } | |
| /** | |
| * Displays the tapped item as a centered square hero overlay. | |
| * | |
| * The overlay animates from [SelectedItem.startBounds] to a centered square sized to the minimum | |
| * screen dimension, preserving [initialCornerRadius]. Its container background fades from white | |
| * to transparent as [progress] approaches 1f, and back to white when collapsing. | |
| */ | |
| @Composable | |
| private fun HeroOverlayFullscreenBounds( | |
| selected: SelectedItem, | |
| progress: Float, | |
| rootSize: IntSize, | |
| initialCornerRadius: Dp, | |
| background: Color, | |
| onBackdropClick: () -> Unit | |
| ) { | |
| val density = LocalDensity.current | |
| val t = progress.coerceIn(-0.10f, 1.02f) | |
| val t01 = progress.coerceIn(0f, 1f) | |
| val rootW = rootSize.width.toFloat() | |
| val rootH = rootSize.height.toFloat() | |
| val side = minOf(rootW, rootH) | |
| val left = (rootW - side) / 2f | |
| val top = (rootH - side) / 2f | |
| val start = selected.startBounds | |
| val target = Rect(left, top, left + side, top + side) | |
| val current = lerpRect(start, target, t) | |
| val cornerPx = with(density) { initialCornerRadius.toPx() } | |
| val heroBgAlpha = lerp(1f, 0f, easeOutCubic(t01)).coerceIn(0f, 1f) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(background.copy(alpha = lerp(0f, 1f, easeOutQuint(t01)))) | |
| .clickable( | |
| indication = null, | |
| interactionSource = remember { MutableInteractionSource() } | |
| ) { onBackdropClick() } | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .offset { IntOffset(current.left.roundToInt(), current.top.roundToInt()) } | |
| .size( | |
| width = with(density) { current.width.toDp() }, | |
| height = with(density) { current.height.toDp() } | |
| ) | |
| .graphicsLayer { | |
| clip = true | |
| shape = RoundedCornerShape(with(density) { cornerPx.toDp() }) | |
| } | |
| .background(Color.White.copy(alpha = heroBgAlpha)) | |
| ) { | |
| selected.item.imageRes?.let { res -> | |
| Image( | |
| painter = painterResource(res), | |
| contentDescription = null, | |
| modifier = Modifier.fillMaxSize(), | |
| contentScale = ContentScale.Crop | |
| ) | |
| } | |
| } | |
| } | |
| private fun Rect.offsetBy(dx: Float, dy: Float): Rect = | |
| Rect(left + dx, top + dy, right + dx, bottom + dy) | |
| private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t | |
| private fun lerpRect(a: Rect, b: Rect, t: Float): Rect = | |
| Rect( | |
| left = lerp(a.left, b.left, t), | |
| top = lerp(a.top, b.top, t), | |
| right = lerp(a.right, b.right, t), | |
| bottom = lerp(a.bottom, b.bottom, t), | |
| ) | |
| private fun easeOutCubic(t: Float): Float { | |
| val x = 1f - t | |
| return 1f - x * x * x | |
| } | |
| private fun easeOutQuint(t: Float): Float { | |
| val x = 1f - t | |
| return 1f - x * x * x * x * x | |
| } | |
| private fun easeInOutCubic(t: Float): Float { | |
| val x = t.coerceIn(0f, 1f) | |
| return if (x < 0.5f) 4f * x * x * x else 1f - (-2f * x + 2f).let { it * it * it } / 2f | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment