Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created November 11, 2025 15:55
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/aaa20ba817325b44f0c44b10f1498675 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/aaa20ba817325b44f0c44b10f1498675 to your computer and use it in GitHub Desktop.
/*
* Copyright 2025 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.
*/
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
import androidx.annotation.DrawableRes
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
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.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Android
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.zengrip.R
import kotlinx.coroutines.launch
import java.util.Random
import kotlin.math.abs
import kotlin.math.sign
/**
* Immutable, per-card placement used to create a natural "messy deck" layout.
*/
private data class Placement(
val dx: Dp,
val baseY: Dp,
val rot: Float,
val scale: Float,
val elevation: Dp
)
private const val VISIBLE_STACK = 5
private val STACK_STEP_Y = 8.dp
private val CARD_HEIGHT_FRACTION = 0.55f
/**
* Static list of drawable resource IDs for Android figures.
*/
private val AndroidFigures: List<Int> = 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,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
/**
* Root screen hosting a playful background and a swipeable deck of Android figures.
*
* @param modifier Optional [Modifier] for the screen container.
* @param figures List of drawable resource IDs to display.
* @param onFinished Callback invoked when the user has swiped all cards away.
*/
fun AndroifyGalleryScreen(
modifier: Modifier = Modifier,
figures: List<Int> = AndroidFigures,
onFinished: () -> Unit = {}
) {
var wallpaperShift by rememberSaveable { mutableStateOf(0f) }
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0B0D10))
) {
AndroidLogosBackground(parallax = wallpaperShift)
PlayfulHeader()
Scaffold(
containerColor = Color.Transparent,
modifier = modifier
) { padding ->
Box(
Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
FigureDeck(
figures = figures,
onFinished = onFinished,
onDragProgress = { wallpaperShift = it },
modifier = Modifier
.fillMaxSize()
.padding(top = 160.dp)
.zIndex(1f)
)
}
}
}
}
@Composable
/**
* Background with large, faint Android logos that subtly parallax in response to dragging.
*
* @param parallax Normalized horizontal shift applied to the background elements.
*/
private fun AndroidLogosBackground(parallax: Float) {
val bg = Color(0xFF0B0D10)
val tint = Color(0xFF3DDC84)
val density = LocalDensity.current
val spots = listOf(
Spot(0.24f, 0.18f, -10f, 0.42f, 0.04f),
Spot(0.76f, 0.20f, 8f, 0.40f, 0.05f),
Spot(0.65f, 0.35f, -2f, 0.40f, 0.05f),
Spot(0.25f, 0.55f, 10f, 0.40f, 0.05f),
Spot(0.75f, 0.65f, -12f, 0.38f, 0.05f),
Spot(0.26f, 0.80f, -5f, 0.40f, 0.04f),
Spot(0.74f, 0.90f, 10f, 0.38f, 0.05f)
)
Box(
Modifier
.fillMaxSize()
.background(bg)
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val widthPx = constraints.maxWidth.toFloat()
val heightPx = constraints.maxHeight.toFloat()
val base = minOf(widthPx, heightPx)
val shift = (widthPx * 0.012f) * parallax.coerceIn(-1f, 1f)
spots.forEach { s ->
val logoSizePx = base * s.sizeFactor
val logoSizeDp = with(density) { logoSizePx.toDp() }
val cx = widthPx * s.nx + shift
val cy = heightPx * s.ny
Icon(
imageVector = Icons.Outlined.Android,
contentDescription = null,
tint = tint,
modifier = Modifier
.offset {
IntOffset(
(cx - logoSizePx / 2f).toInt(),
(cy - logoSizePx / 2f).toInt()
)
}
.size(logoSizeDp)
.graphicsLayer {
rotationZ = s.rot
alpha = s.alpha
}
)
}
}
}
}
@Immutable
private data class Spot(
val nx: Float,
val ny: Float,
val rot: Float,
val sizeFactor: Float,
val alpha: Float
)
@Composable
/**
* Generates one-time random yet stable placements for cards in the deck.
*
* @param count Total number of cards.
* @return List of [Placement] entries corresponding to each card index.
*/
private fun rememberPlacements(count: Int): List<Placement> = remember(count) {
val rnd = Random(2025L)
fun rf(min: Float, max: Float) = (min + rnd.nextFloat() * (max - min))
List(count) { i ->
val depth = i
val baseStep = STACK_STEP_Y * depth
val dx = rf(-10f, 10f).dp
val dy = rf(-6f, 6f).dp
val rot = rf(-3.2f, 3.2f)
val scaleBase = (1f - 0.025f * depth).coerceAtLeast(0.86f)
val scaleJitter = rf(-0.02f, 0f)
val scale = (scaleBase + scaleJitter).coerceAtMost(1f)
val elevation = (28 - depth * 4 + rf(-2f, 2f)).toInt().coerceIn(6, 28).dp
Placement(dx, baseStep + dy, rot, scale, elevation)
}
}
@Composable
/**
* A visible stack of swipeable and static cards. The top card is interactive.
*
* @param figures Drawable resource IDs for each card.
* @param onFinished Invoked when all cards have been dismissed.
* @param onDragProgress Callback with a normalized horizontal drag value for parallax.
* @param modifier Optional [Modifier].
*/
private fun FigureDeck(
figures: List<Int>,
onFinished: () -> Unit,
onDragProgress: (Float) -> Unit,
modifier: Modifier = Modifier
) {
var topIndex by rememberSaveable { mutableStateOf(0) }
val haptics = LocalHapticFeedback.current
val placements = rememberPlacements(figures.size)
val lastIndex = figures.lastIndex
Box(
modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
if (topIndex > lastIndex) {
LaunchedEffect(Unit) { onFinished() }
return@Box
}
val end = (topIndex + VISIBLE_STACK - 1).coerceAtMost(lastIndex)
for (index in end downTo topIndex) {
val p = placements[index]
if (index == topIndex) {
key(index) {
SwipeableCard(
cardId = index,
onDragProgress = onDragProgress,
imageResId = figures[index],
baseScale = 1f,
baseTranslateY = 0.dp,
elevation = p.elevation,
enterFromScale = p.scale,
enterFromTranslateY = p.baseY,
baseDx = p.dx
) { _ ->
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
val next = topIndex + 1
topIndex = next.coerceAtMost(figures.size)
if (next > lastIndex) onFinished()
}
}
} else {
StaticCard(
imageResId = figures[index],
scale = p.scale,
translateY = p.baseY,
elevation = p.elevation,
alpha = if (index == topIndex + 1) 1f else 0.92f,
jitterDx = p.dx,
jitterRot = p.rot,
)
}
}
}
}
@Composable
/**
* Non-interactive card in the stack with jittered placement and rotation.
*/
private fun StaticCard(
@DrawableRes imageResId: Int,
scale: Float,
translateY: Dp,
elevation: Dp,
alpha: Float,
jitterDx: Dp,
jitterRot: Float
) {
ImageCard(
imageResId = imageResId,
elevation = elevation,
cardKey = imageResId,
modifier = Modifier
.offset(x = jitterDx, y = translateY)
.graphicsLayer {
this.alpha = alpha
scaleX = scale
scaleY = scale
rotationZ = jitterRot
}
.zIndex(0f)
)
}
@Composable
/**
* Header text displayed above the deck.
*
* @param title Header title.
* @param subtitle Header subtitle.
*/
private fun PlayfulHeader(
title: String = "Meet the Andro-Crew",
subtitle: String = "Swipe to shuffle the droids →"
) {
Box(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.drawBehind {
drawRect(
Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.18f),
1f to Color.Transparent
)
)
}
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
Column {
Text(
text = title,
color = Color.White,
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-0.5).sp
)
)
Spacer(Modifier.height(2.dp))
Text(
text = subtitle,
color = Color.White.copy(alpha = 0.9f),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
}
}
}
@Composable
/**
* Interactive top card supporting drag, fling, and dismissal animations.
*
* @param cardId Stable ID distinguishing animation states across recompositions.
* @param imageResId Drawable resource to render.
* @param baseScale Target scale after enter animation.
* @param baseTranslateY Target Y translation after enter animation.
* @param elevation Visual elevation used for shadow rendering.
* @param enterFromScale Initial scale for the enter animation.
* @param enterFromTranslateY Initial Y offset for the enter animation.
* @param baseDx Static horizontal offset to stagger the stack.
* @param onDragProgress Callback with a normalized horizontal drag value.
* @param onDismiss Invoked with direction (-1 left, 1 right) when card is dismissed.
*/
private fun SwipeableCard(
cardId: Int,
@DrawableRes imageResId: Int,
baseScale: Float,
baseTranslateY: Dp,
elevation: Dp,
enterFromScale: Float,
enterFromTranslateY: Dp,
baseDx: Dp,
onDragProgress: (Float) -> Unit,
onDismiss: (direction: Int) -> Unit,
) {
val scope = rememberCoroutineScope()
val offsetX = remember(cardId) { Animatable(0f) }
val offsetY = remember(cardId) { Animatable(0f) }
val rotationZ = remember(cardId) { Animatable(0f) }
val scaleAnim = remember(cardId) { Animatable(enterFromScale) }
val yAnim = remember(cardId) { Animatable(enterFromTranslateY.value) }
val liftAnim = remember(cardId) { Animatable(0f) }
LaunchedEffect(cardId, baseScale, baseTranslateY) {
scaleAnim.snapTo(enterFromScale)
yAnim.snapTo(enterFromTranslateY.value)
liftAnim.snapTo(0f)
scaleAnim.animateTo(baseScale, spring(stiffness = 260f, dampingRatio = 0.86f))
yAnim.animateTo(baseTranslateY.value, spring(stiffness = 280f, dampingRatio = 0.9f))
liftAnim.animateTo(1f, spring(stiffness = 220f, dampingRatio = 0.92f))
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val widthPx = constraints.maxWidth.toFloat()
var lastVX = 0f
fun settleOrDismiss() {
val threshold = widthPx * 0.22f
val x = offsetX.value
val dir = sign(x).toInt().coerceIn(-1, 1)
val strongFling = abs(lastVX) > 2200f
if (abs(x) > threshold || strongFling) {
scope.launch {
offsetX.animateTo(dir * widthPx * 1.25f, tween(220))
onDragProgress(0f)
onDismiss(dir)
}
} else {
scope.launch {
offsetX.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = 0.9f)
)
offsetY.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = 0.9f)
)
rotationZ.animateTo(0f, tween(220))
onDragProgress(0f)
}
}
}
val drag: suspend (dx: Float, dy: Float, vx: Float, vy: Float) -> Unit = { dx, dy, vx, _ ->
offsetX.snapTo(offsetX.value + dx)
offsetY.snapTo(offsetY.value + dy / 3f)
rotationZ.snapTo((offsetX.value / 44f).coerceIn(-10f, 10f))
lastVX = vx
onDragProgress((offsetX.value / (widthPx * 0.65f)).coerceIn(-1f, 1f))
}
val dragScale by remember(cardId) {
derivedStateOf {
val frac = (abs(offsetX.value) / (widthPx * 0.6f)).coerceIn(0f, 1f)
1f - 0.02f * frac
}
}
val downBias by remember(cardId) {
derivedStateOf {
val frac = (abs(offsetX.value) / widthPx).coerceIn(0f, 1f)
8f * frac
}
}
val liftedElevation = elevation * (0.85f + 0.15f * liftAnim.value)
ImageCard(
imageResId = imageResId,
elevation = liftedElevation,
cardKey = cardId,
modifier = Modifier
.offset(x = baseDx, y = Dp(yAnim.value))
.graphicsLayer {
scaleX = scaleAnim.value * dragScale
scaleY = scaleAnim.value * dragScale
translationX = offsetX.value
translationY = offsetY.value + downBias
this.rotationZ = rotationZ.value
cameraDistance = 36_000f
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.85f)
}
.pointerInput(cardId) {
var tracker = VelocityTracker()
detectDragGestures(
onDragStart = {
tracker = VelocityTracker()
},
onDragCancel = {
settleOrDismiss()
},
onDragEnd = {
val v = tracker.calculateVelocity()
lastVX = v.x
settleOrDismiss()
}
) { change, dragAmount ->
tracker.addPosition(change.uptimeMillis, change.position)
change.consume()
scope.launch {
drag(dragAmount.x, dragAmount.y, 0f, 0f)
}
}
}
.zIndex(1f)
)
}
}
@Composable
/**
* Card container with styling, background, soft shadow and the provided image.
*
* @param imageResId Drawable resource to render inside the card.
* @param elevation Visual elevation used for shadow rendering.
* @param cardKey Stable key used to derive a consistent wash color.
* @param modifier Optional [Modifier].
*/
private fun ImageCard(
@DrawableRes imageResId: Int,
elevation: Dp,
cardKey: Int,
modifier: Modifier = Modifier
) {
val shape = RoundedCornerShape(28.dp)
val wash = rememberCardWashColor(cardKey)
Box(
modifier
.fillMaxWidth()
.fillMaxHeight(CARD_HEIGHT_FRACTION)
.shadow(
elevation = elevation / 6,
shape = shape,
ambientColor = Color(0x22182434),
spotColor = Color(0x22182434)
)
.softCardShadow(alpha = 0.16f, spread = 0.9f, lift = 0.32f)
.clip(shape)
.background(Color(0xFFFBFCFE))
.border(1.dp, Color(0x14000000), shape)
.drawBehind {
drawRoundRect(
color = Color.White.copy(alpha = 0.45f),
topLeft = Offset(1f, 1f),
size = Size(size.width - 2f, size.height - 2f),
cornerRadius = CornerRadius(28.dp.toPx(), 28.dp.toPx()),
style = Stroke(width = 1f)
)
drawRect(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.25f),
0.3f to Color.White.copy(alpha = 0.10f),
1f to Color.Transparent
)
)
}
) {
Image(
painter = painterResource(imageResId),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
.height(4.dp)
.fillMaxWidth(0.14f)
.clip(RoundedCornerShape(50))
.background(Color(0x1A000000))
)
}
}
/**
* Adds a soft, radial shadow beneath a card for depth.
*/
private fun Modifier.softCardShadow(
alpha: Float = 0.18f,
spread: Float = 0.86f,
lift: Float = 0.35f
) = drawBehind {
val w = size.width * spread
val h = size.height * 0.22f
val left = (size.width - w) / 2f
val top = size.height - h * (1f + lift)
drawOval(
brush = Brush.radialGradient(
colors = listOf(Color.Black.copy(alpha = alpha), Color.Transparent),
center = Offset(size.width / 2f, size.height - h * lift),
radius = w
),
topLeft = Offset(left, top),
size = Size(w, h)
)
}
@Composable
/**
* Returns a stable, theme-aware wash color derived from the provided key.
*
* @param cardKey Stable key used to seed the color generator.
*/
private fun rememberCardWashColor(cardKey: Int): Color {
val dark = isSystemInDarkTheme()
val rnd = remember(cardKey) { java.util.Random(cardKey.toLong() * 1103515245L + 12345L) }
val hue = rnd.nextFloat() * 360f
val sat = 0.55f + rnd.nextFloat() * 0.25f
val light = if (dark) 0.45f + rnd.nextFloat() * 0.06f else 0.80f + rnd.nextFloat() * 0.06f
return Color.hsl(hue, sat, light)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment