Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created January 29, 2026 16:59
Show Gist options
  • Select an option

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

Select an option

Save Kyriakos-Georgiopoulos/b9bbae242d04a4be09b05b37c60897aa to your computer and use it in GitHub Desktop.
/*
* 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 android.app.Activity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
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.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.drawWithContent
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
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 androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Data model representing a notification or task item in the list.
*
* @property id Unique identifier for the item.
* @property title The main headline of the item.
* @property description Subtext or body content.
* @property time Timestamp string.
* @property color The accent color used for the icon background.
* @property icon The Vector asset to display.
*/
data class PaperItem(
val id: Int,
val title: String,
val description: String,
val time: String,
val color: Color,
val icon: ImageVector
)
/**
* Data model for bottom navigation elements.
*
* @property icon The icon vector.
* @property label The text label.
*/
data class NavItem(val icon: ImageVector, val label: String)
/**
* The primary screen composable demonstrating advanced realism physics.
*
* Features:
* - 3D Cylinder Roll transition between the main app and the profile menu.
* - Dynamic Status Bar color handling.
* - Shared Element Transition for the Avatar.
* - Physics-based list items with a "paper roll" delete animation.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RollScreen() {
val items = remember {
mutableStateListOf(
PaperItem(1, "Project Titan", "Q4 Financial Report review pending.", "10:30 AM", Color(0xFF6366F1), Icons.Default.ShoppingCart),
PaperItem(2, "Alice Freeman", "Hey! Are we still on for lunch?", "11:15 AM", Color(0xFFEC4899), Icons.Default.Face),
PaperItem(3, "Dribbble Invite", "You have a new prospect message.", "12:00 PM", Color(0xFFEA4C89), Icons.Default.Email),
PaperItem(4, "Design System", "Updated tokens for the dark mode.", "Yesterday", Color(0xFF10B981), Icons.Default.Favorite),
PaperItem(5, "Flight to Tokyo", "Gate change: Terminal 3, Gate 42A.", "Yesterday", Color(0xFF3B82F6), Icons.Default.Place),
PaperItem(6, "Gym Reminder", "Leg day is tomorrow. Don't skip it.", "Oct 24", Color(0xFFF59E0B), Icons.Default.Notifications),
PaperItem(7, "Spotify Renewal", "Premium subscription auto-renewed.", "Oct 22", Color(0xFF1DB954), Icons.Default.PlayArrow),
PaperItem(8, "Uber Ride", "Your ride to Downtown is arriving.", "Oct 20", Color(0xFF000000), Icons.Default.LocationOn),
PaperItem(9, "Slack Notification", "Mark: Can you check the PR?", "Oct 19", Color(0xFF4A154B), Icons.Default.Send),
)
}
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenRollOffset = remember { Animatable(0f) }
var isProfileOpen by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val screenHeightDp = configuration.screenHeightDp.dp
val rollTargetPx = with(density) { (screenHeightDp * 0.55f).toPx() }
val statusBarInsets = WindowInsets.statusBars.asPaddingValues()
val topInset = statusBarInsets.calculateTopPadding()
val safeTopInset = if (topInset < 20.dp) 24.dp else topInset
val closedTopPadding = safeTopInset + 12.dp
val openTopPadding = 16.dp
val view = LocalView.current
val currentRoll = screenRollOffset.value
LaunchedEffect(currentRoll) {
val window = (view.context as Activity).window
val controller = WindowInsetsControllerCompat(window, view)
controller.isAppearanceLightStatusBars = currentRoll < 200f
}
val progress by remember {
derivedStateOf { (screenRollOffset.value / rollTargetPx).coerceIn(0f, 1f) }
}
val contentTopPadding = lerp(closedTopPadding, openTopPadding, progress)
fun toggleProfile() {
scope.launch {
if (isProfileOpen) {
screenRollOffset.animateTo(0f, spring(dampingRatio = 0.85f, stiffness = 300f))
isProfileOpen = false
} else {
isProfileOpen = true
screenRollOffset.animateTo(rollTargetPx, spring(dampingRatio = 0.75f, stiffness = 160f))
}
}
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0F172A))
) {
val screenWidth = maxWidth
ProfileMenuContent(
isOpen = isProfileOpen,
onClose = { toggleProfile() },
sharedAvatarProgress = progress
)
Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(0, screenRollOffset.value.roundToInt()) }
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
val rollPx = screenRollOffset.value
if (rollPx <= 1f) {
drawContent()
return@drawWithContent
}
val width = size.width
val cylinderDiameter = (sqrt(rollPx) * 3.6f).coerceAtLeast(1f)
drawContent()
drawRect(
brush = Brush.verticalGradient(
listOf(Color.Black.copy(0.4f), Color.Transparent),
startY = 0f,
endY = cylinderDiameter * 1.5f
),
size = Size(width, cylinderDiameter * 1.5f)
)
drawRect(
brush = Brush.verticalGradient(
0.0f to Color(0xFFD1D5DB),
0.3f to Color(0xFFFFFFFF),
0.6f to Color(0xFFE5E7EB),
1.0f to Color(0xFF9CA3AF),
startY = -cylinderDiameter,
endY = 0f
),
topLeft = Offset(0f, -cylinderDiameter),
size = Size(width, cylinderDiameter)
)
}
) {
MainAppContent(
items = items,
topPadding = contentTopPadding,
progress = progress
)
}
SharedAvatar(
progress = progress,
screenWidth = screenWidth,
topPadding = closedTopPadding,
onClick = { toggleProfile() }
)
}
}
/**
* The background layer containing the user profile and settings.
*
* @param isOpen Whether the profile is fully revealed.
* @param onClose Callback to close the profile.
* @param sharedAvatarProgress Animation progress of the shared avatar transition.
*/
@Composable
fun ProfileMenuContent(
isOpen: Boolean,
onClose: () -> Unit,
sharedAvatarProgress: Float
) {
val alpha by animateFloatAsState(if (isOpen) 1f else 0f, tween(400), label = "alpha")
val slide by animateFloatAsState(if (isOpen) 0f else -30f, spring(0.7f, 200f), label = "slide")
val scale by animateFloatAsState(if (isOpen) 1f else 0.95f, spring(0.7f, 200f), label = "scale")
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
listOf(Color(0xFF2E1065), Color(0xFF0F172A), Color(0xFF000000)),
center = Offset(500f, 300f),
radius = 1800f
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(top = 16.dp, start = 24.dp, end = 24.dp)
.verticalScroll(scrollState)
.graphicsLayer {
this.alpha = alpha
this.translationY = slide
this.scaleX = scale
this.scaleY = scale
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.size(100.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Kyriakos G.",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Black,
letterSpacing = (-0.5).sp
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
color = Color(0xFF8B5CF6).copy(0.15f),
shape = RoundedCornerShape(100.dp),
border = BorderStroke(1.dp, Color(0xFF8B5CF6).copy(0.3f))
) {
Text(
text = "PRO MEMBER",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
color = Color(0xFFC4B5FD),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
BentoCard(Icons.Default.Settings, "System", "v4.0", Color(0xFF3B82F6), Modifier.weight(1f))
BentoCard(Icons.Default.Notifications, "Pings", "12", Color(0xFFF59E0B), Modifier.weight(1f))
BentoCard(Icons.Default.Lock, "Security", "Safe", Color(0xFF10B981), Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(0.05f))
.border(
1.dp,
Brush.verticalGradient(listOf(Color.White.copy(0.15f), Color.White.copy(0.02f))),
RoundedCornerShape(20.dp)
)
.clickable { }
.padding(16.dp),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colors = listOf(Color(0xFFEC4899).copy(0.1f), Color.Transparent)
)
)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFFEC4899).copy(0.2f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null,
tint = Color(0xFFF472B6),
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Buy the Devs a Taco",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
Text(
text = "Priority support & badges",
color = Color.White.copy(0.5f),
fontSize = 12.sp
)
}
}
}
}
}
}
/**
* A square card used in the Bento grid layout.
*
* @param icon The icon to display.
* @param label The label text.
* @param value The main value text.
* @param color The theme color for the icon.
* @param modifier Modifier for layout adjustments.
*/
@Composable
fun BentoCard(
icon: ImageVector,
label: String,
value: String,
color: Color,
modifier: Modifier
) {
Column(
modifier = modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(24.dp))
.background(Color.White.copy(0.05f))
.border(
1.dp,
Brush.linearGradient(listOf(Color.White.copy(0.1f), Color.White.copy(0.02f))),
RoundedCornerShape(24.dp)
)
.clickable { }
.padding(14.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.height(12.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = label,
color = Color.White.copy(0.5f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
/**
* The main scrollable content area of the application.
*
* @param items List of [PaperItem] to display.
* @param topPadding Dynamic padding that changes based on the roll animation state.
* @param progress float representing the percentage of the screen roll animation (0f to 1f).
*/
@Composable
fun MainAppContent(
items: MutableList<PaperItem>,
topPadding: Dp,
progress: Float
) {
val density = LocalDensity.current
val spacerWidth = lerp(56.dp, 0.dp, progress)
val listTopPadding = topPadding + 44.dp + 24.dp
val listState = rememberLazyListState()
var rawBottomOffset by remember { mutableStateOf(0f) }
val isScrolling by remember { derivedStateOf { listState.isScrollInProgress } }
val scrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
rawBottomOffset = (rawBottomOffset - available.y).coerceIn(0f, with(density) { 120.dp.toPx() })
return Offset.Zero
}
}
}
LaunchedEffect(isScrolling) {
if (!isScrolling) {
delay(150)
Animatable(rawBottomOffset).animateTo(0f, spring(0.5f, 200f)) {
rawBottomOffset = value
}
}
}
val navProgress = (rawBottomOffset / with(density) { 120.dp.toPx() }).coerceIn(0f, 1f)
val navAlpha = 1f - (navProgress * 0.5f)
val navScale = 1f - (navProgress * 0.1f)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF2F2F7))
.nestedScroll(scrollConnection)
) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(top = listTopPadding, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize()
) {
items(items, key = { it.id }) { item ->
Box(modifier = Modifier.animateItem()) {
ShadowItem(item = item, onDelete = { items.remove(item) })
}
}
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.zIndex(1f)
.background(Color(0xFFF2F2F7).copy(alpha = 0.95f))
.padding(top = topPadding)
.height(44.dp),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(spacerWidth))
SearchBar(modifier = Modifier.weight(1f))
}
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.offset { IntOffset(0, rawBottomOffset.roundToInt()) }
) {
FloatingIslandNav(scale = navScale, alpha = navAlpha)
}
}
}
/**
* A stylized search bar component.
*/
@Composable
fun SearchBar(modifier: Modifier = Modifier) {
var text by remember { mutableStateOf("") }
Box(
modifier = modifier
.shadow(4.dp, RoundedCornerShape(24.dp), spotColor = Color.Black.copy(0.04f))
.background(Color.White, RoundedCornerShape(24.dp))
.height(44.dp)
.padding(horizontal = 16.dp)
.clickable { },
contentAlignment = Alignment.CenterStart
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize()
) {
Icon(
Icons.Rounded.Search,
null,
tint = Color(0xFF9CA3AF),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
if (text.isEmpty()) {
Text(
text = "Search bugs...",
color = Color(0xFF9CA3AF),
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
BasicTextField(
value = text,
onValueChange = { text = it },
textStyle = TextStyle(
fontSize = 15.sp,
color = Color(0xFF1F2937),
fontWeight = FontWeight.Medium
),
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
/**
* A shared avatar component that transitions from a small icon in the top bar
* to a large profile picture in the menu.
*
* @param progress Animation progress (0f = small/closed, 1f = large/open).
* @param screenWidth Width of the screen for centering calculations.
* @param topPadding Top padding reference for the starting position.
* @param onClick Action to perform on click.
*/
@Composable
fun SharedAvatar(
progress: Float,
screenWidth: Dp,
topPadding: Dp,
onClick: () -> Unit
) {
val statusBarInsets = WindowInsets.statusBars.asPaddingValues()
val topInset = statusBarInsets.calculateTopPadding()
val startSize = 44.dp
val startX = 16.dp
val startY = topPadding
val startFontSize = 14.sp
val endSize = 100.dp
val endX = (screenWidth - endSize) / 2
val endY = topInset + 16.dp
val endFontSize = 28.sp
val currentSize = lerp(startSize, endSize, progress)
val currentX = lerp(startX, endX, progress)
val currentY = lerp(startY, endY, progress)
val currentFontSize = lerp(startFontSize, endFontSize, progress)
val brush = Brush.linearGradient(
colors = listOf(
Color(0xFF6366F1),
androidx.compose.ui.graphics.lerp(Color(0xFF8B5CF6), Color(0xFFEC4899), progress)
)
)
Box(
modifier = Modifier
.offset { IntOffset(currentX.roundToPx(), currentY.roundToPx()) }
.size(currentSize)
.border(
width = 2.dp * progress,
color = Color(0xFF8B5CF6).copy(alpha = 0.3f * progress),
shape = CircleShape
)
.padding(4.dp * progress)
.shadow(
elevation = 4.dp + (12.dp * progress),
shape = CircleShape,
spotColor = Color.Black.copy(0.3f)
)
.clip(CircleShape)
.background(brush)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Text(
text = "KG",
color = Color.White,
fontWeight = FontWeight.Black,
fontSize = currentFontSize,
modifier = Modifier.graphicsLayer {
val scale = 1f + (0.1f * sin(progress * PI.toFloat()))
scaleX = scale
scaleY = scale
}
)
}
}
/**
* Floating navigation bar with smooth scaling and opacity transitions.
*/
@Composable
fun FloatingIslandNav(
scale: Float,
alpha: Float
) {
val navItems = listOf(
NavItem(Icons.Default.Home, "Home"),
NavItem(Icons.Outlined.ShoppingCart, "Wallet"),
NavItem(Icons.Outlined.Email, "Chat"),
NavItem(Icons.Outlined.Settings, "Setup")
)
var selectedIndex by remember { mutableStateOf(0) }
Card(
modifier = Modifier
.height(64.dp)
.width(280.dp)
.graphicsLayer {
this.scaleX = scale
this.scaleY = scale
this.alpha = alpha
this.clip = false
this.shadowElevation = 12.dp.toPx()
this.shape = CircleShape
this.spotShadowColor = Color.Black.copy(0.25f)
},
shape = CircleShape,
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(0.dp)
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
navItems.forEachIndexed { index, item ->
val isSelected = index == selectedIndex
val color by animateColorAsState(if (isSelected) Color(0xFF1F2937) else Color(0xFF9CA3AF), label = "iconColor")
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { selectedIndex = index }
.padding(8.dp)
) {
Icon(item.icon, null, tint = color, modifier = Modifier.size(24.dp))
if (isSelected) Box(
modifier = Modifier
.size(4.dp)
.background(Color(0xFF6366F1), CircleShape)
)
}
}
}
}
}
/**
* A list item that implements a realistic "Paper Roll" deletion effect.
*
* When dragged, the item rolls up like a piece of paper, revealing a delete icon behind it.
* If dragged past a threshold or tapped while rolled, it triggers the delete action.
*
* @param item The data item to display.
* @param onDelete Callback triggered when the item is fully deleted.
*/
@Composable
fun ShadowItem(
item: PaperItem,
onDelete: () -> Unit
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
var isVisible by remember { mutableStateOf(true) }
var itemSize by remember { mutableStateOf(IntSize.Zero) }
val offsetX = remember { Animatable(0f) }
val actionWidth = 120.dp
val actionWidthPx = with(density) { actionWidth.toPx() }
Box(
modifier = Modifier
.onSizeChanged { itemSize = it }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
if (isVisible) {
scope.launch {
val currentX = offsetX.value
val maxRollDistance = -(itemSize.width.toFloat() * 0.9f)
val newOffset = (currentX + delta * 0.7f).coerceIn(maxRollDistance, 0f)
offsetX.snapTo(newOffset)
}
}
},
onDragStopped = { velocity ->
if (isVisible) {
scope.launch {
val threshold = -actionWidthPx * 0.5f
if (offsetX.value < threshold || velocity < -1000f) {
offsetX.animateTo(
-actionWidthPx,
spring(dampingRatio = 0.6f, stiffness = 120f)
)
} else {
offsetX.animateTo(0f, spring(dampingRatio = 0.6f))
}
}
}
}
)
.pointerInput(Unit) {
detectTapGestures(
onTap = { tapOffset ->
val width = itemSize.width.toFloat()
val currentRoll = offsetX.value
val visualBoundary = width + currentRoll
if (tapOffset.x > visualBoundary && isVisible) {
scope.launch {
launch {
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(dampingRatio = 1f, stiffness = 600f)
)
}
delay(350)
isVisible = false
delay(300)
onDelete()
}
}
}
)
}
) {
AnimatedVisibility(
visible = isVisible,
exit = shrinkVertically(tween(300)) + fadeOut(tween(300))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
Box(
modifier = Modifier
.matchParentSize()
.padding(horizontal = 16.dp)
.background(Color(0xFFFF3B30), RoundedCornerShape(16.dp))
.padding(end = 32.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
"Delete",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
val rollPx = offsetX.value
val rollDistance = rollPx.absoluteValue
if (rollDistance <= 1f) {
this@drawWithContent.drawContent()
return@drawWithContent
}
val width = size.width
val height = size.height
val targetDiameter = sqrt(rollDistance) * 3.8f
val startThreshold = 30f
val formFactor = (rollDistance / startThreshold).coerceIn(0f, 1f)
val cylinderDiameter = (targetDiameter * formFactor).coerceAtLeast(0.1f)
val cutX = (width + rollPx).coerceAtLeast(0f)
clipRect(right = cutX) { this@drawWithContent.drawContent() }
if (cutX > 0 && cylinderDiameter > 1f) {
val cylinderLeft = cutX
val perspectiveRatio = 0.1f + (0.12f * formFactor)
val capHeight = cylinderDiameter * perspectiveRatio
val creaseWidth = (cylinderDiameter * 0.5f).coerceAtMost(14.dp.toPx())
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.06f * formFactor)),
startX = cutX - creaseWidth,
endX = cutX
),
topLeft = Offset(cutX - creaseWidth, 0f),
size = Size(creaseWidth, height)
)
val shadowWidth = cylinderDiameter * 1.1f
if (shadowWidth > 1f) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Black.copy(alpha = (0.2f * formFactor).coerceIn(0f, 0.3f)),
Color.Transparent
),
startX = cylinderLeft,
endX = cylinderLeft + shadowWidth
),
topLeft = Offset(cylinderLeft, 0f),
size = Size(shadowWidth, height)
)
}
drawRect(
brush = Brush.horizontalGradient(
0.0f to Color(0xFFD1D1D6).copy(alpha = formFactor),
0.2f to Color(0xFFFFFFFF).copy(alpha = formFactor),
0.5f to Color(0xFFF2F2F7).copy(alpha = formFactor),
0.8f to Color(0xFFE8D5D5).copy(alpha = formFactor),
1.0f to Color(0xFF98989D).copy(alpha = formFactor),
startX = cylinderLeft,
endX = cylinderLeft + cylinderDiameter
),
topLeft = Offset(cylinderLeft, 0f),
size = Size(cylinderDiameter, height)
)
if (formFactor > 0.9f) {
val grainStep = 4.dp.toPx()
val steps = (cylinderDiameter / grainStep).toInt()
for (i in 0 until steps) {
val xPos = cylinderLeft + (i * grainStep)
drawLine(
Color.Black.copy(alpha = 0.03f),
Offset(xPos, 0f),
Offset(xPos, height)
)
}
}
if (cylinderDiameter > 6.dp.toPx() && formFactor > 0.8f) {
val capRadius = cylinderDiameter / 2f
val capGradient = Brush.radialGradient(
listOf(Color(0xFFFFFFFF), Color(0xFFE0E0E5), Color(0xFFD1D1D6)),
radius = capRadius
)
fun drawCap(yPos: Float) {
val capRect = Rect(Offset(cylinderLeft, yPos), Size(cylinderDiameter, capHeight))
drawOval(brush = capGradient, topLeft = capRect.topLeft, size = capRect.size)
val spiralPath = Path().apply {
val cx = capRect.center.x
val cy = capRect.center.y
val maxR = capRect.width / 2f
val rotations = 1.5f + (rollDistance / 150f)
val spiralSteps = (60 * rotations).toInt()
val totalAngle = 2 * PI.toFloat() * rotations
moveTo(cx, cy)
for (i in 0..spiralSteps) {
val t = i / spiralSteps.toFloat()
val angle = t * totalAngle
val r = t * maxR
lineTo(
cx + (r * cos(angle)),
cy + (r * sin(angle) * perspectiveRatio)
)
}
}
drawPath(
spiralPath,
Color.Black.copy(alpha = 0.15f * formFactor),
style = Stroke(1.5f)
)
drawOval(
Color.Black.copy(alpha = 0.1f),
capRect.topLeft,
capRect.size,
style = Stroke(1f)
)
}
drawCap(-capHeight / 2)
drawCap(height - capHeight / 2)
}
}
}
) {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(0.dp),
modifier = Modifier.fillMaxSize()
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(56.dp)
.background(item.color.copy(alpha = 0.1f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
painter = rememberVectorPainter(item.icon),
contentDescription = null,
tint = item.color,
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = Color(0xFF1F2937)
)
Text(
text = item.time,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF9CA3AF)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = item.description,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF6B7280),
maxLines = 1
)
}
}
}
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment