Created
January 29, 2026 16:59
-
-
Save Kyriakos-Georgiopoulos/b9bbae242d04a4be09b05b37c60897aa 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 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