Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save Kyriakos-Georgiopoulos/c7360aad4fcf8d08b1c8b8d4cd1f0552 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 androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
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.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.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.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Brush
import androidx.compose.material.icons.rounded.ChatBubble
import androidx.compose.material.icons.rounded.DirectionsCar
import androidx.compose.material.icons.rounded.EmojiEmotions
import androidx.compose.material.icons.rounded.Face
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.SentimentSatisfied
import androidx.compose.material.icons.rounded.SportsEsports
import androidx.compose.material.icons.rounded.SupervisedUserCircle
import androidx.compose.material.icons.rounded.Work
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
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 kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
//region --- Composition Locals ---
/**
* Provides the [SharedTransitionScope] to children without passing it as a function parameter.
* This avoids `VerifyError` crashes on certain Android runtimes caused by complex method signatures.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
/**
* Provides the [AnimatedVisibilityScope] to children, required for Shared Element transitions.
*/
val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
//endregion
//region --- Animation Specs ---
/**
* A custom spring configuration for Shared Element Transitions.
* Low stiffness (100f) creates a slow, floaty movement.
* Damping ratio (0.85f) ensures a soft, organic landing without excessive vibration.
*/
val playfulSpring = spring<Rect>(
dampingRatio = 0.85f,
stiffness = 100f
)
//endregion
//region --- Data Models ---
data class TabItem(
val title: String,
val color: Color
)
data class RecentMessage(
val id: Int,
val name: String,
val message: String,
val time: String,
val isOnline: Boolean,
val icon: ImageVector
)
data class ChatMessage(
val id: Int,
val text: String,
val isFromMe: Boolean,
val time: String
)
//endregion
//region --- Navigation ---
sealed class Screen {
data object Home : Screen()
data class Chat(val user: RecentMessage) : Screen()
}
//endregion
//region --- Root Composable ---
/**
* The root navigation controller.
* Handles state hoisting for list animations to ensure lists remain static when returning from a chat,
* allowing the Shared Element Transition to find its target frame.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun AppNavigation() {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
// -- State Hoisting --
// We hoist the tab index so it persists when the Home screen is recreated after a back press.
var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) }
// We hoist the animation flag.
// true = List items fly in (App start or Tab switch).
// false = List items are static (Return from Chat).
var shouldAnimateList by rememberSaveable { mutableStateOf(true) }
// We apply a solid background to the root Layout to prevent the white window background
// from flashing through during the double-transparent cross-fade.
SharedTransitionLayout(
modifier = Modifier
.fillMaxSize()
.background(color = Color(0xFF1C1B2A))
) {
AnimatedContent(
targetState = currentScreen,
label = "ScreenTransition",
transitionSpec = {
// Smooth cross-fade to match the slow shared element spring
fadeIn(
animationSpec = tween(durationMillis = 600)
) togetherWith fadeOut(
animationSpec = tween(durationMillis = 600)
)
}
) { targetScreen ->
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
LocalAnimatedVisibilityScope provides this@AnimatedContent
) {
when (targetScreen) {
is Screen.Home -> {
HeaderTabsFinal(
selectedIndex = selectedTabIndex,
shouldAnimate = shouldAnimateList,
onTabSelected = { newIndex ->
selectedTabIndex = newIndex
// Tab switch -> Trigger list entrance animation
shouldAnimateList = true
},
onChatSelected = { user ->
// Navigating away -> Freeze list state for return
shouldAnimateList = false
currentScreen = Screen.Chat(user)
}
)
}
is Screen.Chat -> {
ChatDetailScreen(
user = targetScreen.user,
onBack = { currentScreen = Screen.Home }
)
}
}
}
}
}
}
//endregion
//region --- Screens ---
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ChatDetailScreen(
user: RecentMessage,
onBack: () -> Unit
) {
// Safe unwrap of scopes. If null, the screen still renders, just without shared transitions.
val sharedTransitionScope = LocalSharedTransitionScope.current ?: return
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current ?: return
val screenColor = Color(0xFF1C1B2A)
val headerColor = Color(0xFF2B2939)
Column(
modifier = Modifier
.fillMaxSize()
.background(screenColor)
) {
// --- Header ---
Row(
modifier = Modifier
.fillMaxWidth()
.background(headerColor)
.statusBarsPadding()
.height(70.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Spacer(modifier = Modifier.width(8.dp))
// SHARED ELEMENT: AVATAR
with(sharedTransitionScope) {
Box(
modifier = Modifier
.sharedElement(
sharedContentState = rememberSharedContentState(key = "avatar-${user.id}"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> playfulSpring }
)
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF3E3C4E)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = user.icon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// SHARED ELEMENT: NAME
// Note: We use `sharedBounds` + `scaleToBounds` here.
// This prevents the text from re-flowing (wrapping) as it animates, ensuring
// the surname doesn't disappear during the transition.
with(sharedTransitionScope) {
Text(
text = user.name,
style = TextStyle(
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold
),
maxLines = 1,
modifier = Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = "name-${user.id}"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> playfulSpring },
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart
)
)
)
}
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = Color.White,
modifier = Modifier.padding(end = 16.dp)
)
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
tint = Color.White,
modifier = Modifier.padding(end = 8.dp)
)
}
// --- Chat Content ---
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(chatDummyData) { index, msg ->
MessageBubble(msg = msg, index = index)
}
}
// --- Input Bar ---
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.height(56.dp)
.background(Color(0xFF2B2939), RoundedCornerShape(28.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Attach",
tint = Color.White.copy(0.5f)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Write",
color = Color.White.copy(0.3f),
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(40.dp)
.background(Color(0xFF6C63FF), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
@Composable
fun HeaderTabsFinal(
selectedIndex: Int,
shouldAnimate: Boolean,
onTabSelected: (Int) -> Unit,
onChatSelected: (RecentMessage) -> Unit
) {
// Layout Constants
val topSpace = 80.dp
val tabTextStyle = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
val flareWidth = 56.dp
val flareHeight = 36.dp
val bottomCornerRadius = 26.dp
val tabs = listOf(
TabItem("Recents", Color(0xFF6C63FF)),
TabItem("Favorites", Color(0xFFFF4D86)),
TabItem("Groups", Color(0xFF2ED3B7))
)
// Animate header background color based on selection
val headerColor by animateColorAsState(
targetValue = tabs[selectedIndex].color,
animationSpec = spring(dampingRatio = 0.75f, stiffness = 200f),
label = "headerColor"
)
val density = LocalDensity.current
val noRipple = remember { MutableInteractionSource() }
// Dynamic tab sizing for the indicator
var tabBounds by remember(tabs.size) { mutableStateOf(List(tabs.size) { Rect.Zero }) }
val target = tabBounds.getOrNull(selectedIndex) ?: Rect.Zero
val isFirst = selectedIndex == 0
val isLast = selectedIndex == tabs.size - 1
val hasStartFlare = !isFirst
val hasEndFlare = !isLast
// Calculate Indicator Position and Width
val targetX =
if (hasStartFlare) target.left.toDp(density) - flareWidth else target.left.toDp(density)
val indicatorX by animateDpAsState(
targetValue = targetX,
animationSpec = spring(dampingRatio = 0.75f, stiffness = 200f),
label = "X"
)
val widthAdjustment =
(if (hasStartFlare) flareWidth else 0.dp) + (if (hasEndFlare) flareWidth else 0.dp)
val indicatorW by animateDpAsState(
targetValue = target.width.toDp(density) + widthAdjustment,
animationSpec = spring(dampingRatio = 0.75f, stiffness = 200f),
label = "W"
)
val screenColor = Color(0xFF1C1B2A)
// Search Logic
var isSearchActive by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val maxSearchWidth = screenWidth - 24.dp
// Playful Search Expansion Animation
val searchWidthFraction by animateFloatAsState(
targetValue = if (isSearchActive) 1f else 0f,
animationSpec = spring(
dampingRatio = 0.7f, // Jelly-like bounce
stiffness = 200f // Smooth speed
),
label = "SearchWidth"
)
// Persist scroll states
val recentsState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
val favoritesState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
val groupsState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
Column(
modifier = Modifier
.fillMaxSize()
.background(screenColor)
) {
// --- Header Section ---
Column(
modifier = Modifier
.fillMaxWidth()
.background(headerColor)
.statusBarsPadding()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(topSpace)
.padding(horizontal = 8.dp)
) {
// Add Button (Left)
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 8.dp)
.graphicsLayer {
// Shrink add button when search expands
val s = 1f - searchWidthFraction
scaleX = s
scaleY = s
alpha = s
}
.size(60.dp)
.clip(CircleShape)
.clickable(
interactionSource = noRipple,
indication = null
) { /* Add Action */ },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add",
tint = Color.White,
modifier = Modifier.requiredSize(28.dp)
)
}
// Search Bar (Right)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.height(60.dp),
contentAlignment = Alignment.CenterEnd
) {
// Background Pill
Box(
modifier = Modifier
.width(maxSearchWidth * searchWidthFraction)
.height(50.dp)
.clip(RoundedCornerShape(25.dp))
.background(Color.White.copy(alpha = 0.25f))
)
// Text Field
if (searchWidthFraction > 0.1f) {
Row(
modifier = Modifier
.width(maxSearchWidth * searchWidthFraction)
.height(50.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.padding(start = 20.dp),
contentAlignment = Alignment.CenterStart
) {
BasicTextField(
value = searchText,
onValueChange = { searchText = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.alpha(searchWidthFraction.coerceIn(0f, 1f)),
textStyle = TextStyle(color = Color.White, fontSize = 20.sp),
cursorBrush = SolidColor(Color.White),
singleLine = true,
decorationBox = { innerTextField ->
if (searchText.isEmpty()) {
Text(
text = "Search...",
color = Color.White.copy(0.6f),
fontSize = 20.sp
)
}
innerTextField()
}
)
}
// Spacer to prevent text overlapping button area
Spacer(modifier = Modifier.width(60.dp))
}
}
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
delay(100)
focusRequester.requestFocus()
}
}
// Search/Close Icon Button
Box(
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.clickable(interactionSource = noRipple, indication = null) {
isSearchActive = !isSearchActive
if (!isSearchActive) searchText = ""
},
contentAlignment = Alignment.Center
) {
// We use AnimatedContent with Scale+Fade to prevent visual overlapping
AnimatedContent(
targetState = isSearchActive,
transitionSpec = {
(scaleIn(animationSpec = tween(300)) + fadeIn(
animationSpec = tween(
300
)
))
.togetherWith(
scaleOut(animationSpec = tween(300)) + fadeOut(
animationSpec = tween(
300
)
)
)
},
label = "IconAnim"
) { active ->
Icon(
imageVector = if (active) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = null,
tint = Color.White,
modifier = Modifier.requiredSize(28.dp)
)
}
}
}
}
}
// --- Tabs Section ---
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.background(screenColor)
) {
// Animated Indicator (The "Gooey" Background)
if (target.width > 0f) {
Box(
modifier = Modifier
.offset(x = indicatorX, y = (-1).dp)
.width(indicatorW)
.height(57.dp)
.background(
color = headerColor,
shape = getUltraSmoothedEdgesShape(
flareWidth = with(density) { flareWidth.toPx() },
flareHeight = with(density) { flareHeight.toPx() },
cornerSize = with(density) { bottomCornerRadius.toPx() },
hasStartFlare = hasStartFlare,
hasEndFlare = hasEndFlare
)
)
)
}
// Tab Text Items
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
tabs.forEachIndexed { index, tab ->
val selected = index == selectedIndex
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(interactionSource = noRipple, indication = null) {
onTabSelected(index)
}
.onGloballyPositioned { coords ->
val pos = coords.positionInParent()
tabBounds = tabBounds
.toMutableList()
.also { list ->
list[index] = Rect(
pos.x,
pos.y,
pos.x + coords.size.width,
pos.y + coords.size.height
)
}
},
contentAlignment = Alignment.Center
) {
Text(
text = tab.title,
color = if (selected) Color.White else Color.White.copy(alpha = 0.5f),
style = tabTextStyle
)
}
}
}
}
// --- Content Section ---
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(screenColor)
) {
when (selectedIndex) {
0 -> RecentsListShared(
items = dummyRecents,
state = recentsState,
onChatSelected = onChatSelected,
shouldAnimate = shouldAnimate
)
1 -> FavoritesListShared(
items = dummyFavorites,
state = favoritesState,
onChatSelected = onChatSelected,
shouldAnimate = shouldAnimate
)
2 -> GroupsListShared(
items = dummyGroups,
state = groupsState,
onChatSelected = onChatSelected,
shouldAnimate = shouldAnimate
)
}
BottomNavBar(modifier = Modifier.align(Alignment.BottomCenter))
}
}
}
//endregion
//region --- Lists ---
@Composable
fun RecentsListShared(
items: List<RecentMessage>,
state: LazyListState,
onChatSelected: (RecentMessage) -> Unit,
shouldAnimate: Boolean
) {
BoxWithConstraints {
val startOffset = -maxWidth
LazyColumn(
state = state,
contentPadding = PaddingValues(top = 16.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
itemsIndexed(items) { index, item ->
// Animation Logic:
// If shouldAnimate is true, we initialize at 0f/offset and animate to 1f/0.
// If false, we initialize directly at 1f/0 (Static).
val alphaAnim = remember { Animatable(if (shouldAnimate) 0f else 1f) }
val slideAnim =
remember { Animatable(if (shouldAnimate) startOffset.value else 0f) }
if (shouldAnimate) {
LaunchedEffect(Unit) {
delay(index * 60L)
launch { alphaAnim.animateTo(1f, tween(400)) }
launch { slideAnim.animateTo(0f, spring(0.8f, Spring.StiffnessLow)) }
}
}
Box(
modifier = Modifier
.offset(x = slideAnim.value.dp)
.alpha(alphaAnim.value)
) {
SharedRecentItemRow(item = item, onChatSelected = onChatSelected)
}
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun FavoritesListShared(
items: List<RecentMessage>,
state: LazyListState,
onChatSelected: (RecentMessage) -> Unit,
shouldAnimate: Boolean
) {
var favorites by remember { mutableStateOf(items) }
LazyColumn(
state = state,
contentPadding = PaddingValues(top = 16.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
itemsIndexed(favorites, key = { _, item -> item.id }) { index, item ->
val alphaAnim = remember { Animatable(if (shouldAnimate) 0f else 1f) }
val offsetYAnim = remember { Animatable(if (shouldAnimate) -100f else 0f) }
if (shouldAnimate) {
LaunchedEffect(Unit) {
delay(index * 100L)
launch { alphaAnim.animateTo(1f, tween(500)) }
launch {
offsetYAnim.animateTo(
0f,
spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow)
)
}
}
}
Box(
modifier = Modifier
.graphicsLayer {
alpha = alphaAnim.value
translationY = offsetYAnim.value.dp.toPx()
}
) {
DraggableFavoriteItemShared(
item = item,
onDelete = {
favorites = favorites.toMutableList().also { it.remove(item) }
},
onChatSelected = onChatSelected
)
}
}
}
}
@Composable
fun GroupsListShared(
items: List<RecentMessage>,
state: LazyListState,
onChatSelected: (RecentMessage) -> Unit,
shouldAnimate: Boolean
) {
BoxWithConstraints {
val startOffset = maxWidth
LazyColumn(
state = state,
contentPadding = PaddingValues(top = 16.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
itemsIndexed(items) { index, item ->
val alphaAnim = remember { Animatable(if (shouldAnimate) 0f else 1f) }
val slideAnim =
remember { Animatable(if (shouldAnimate) startOffset.value else 0f) }
if (shouldAnimate) {
LaunchedEffect(Unit) {
delay(index * 60L)
launch { alphaAnim.animateTo(1f, tween(400)) }
launch { slideAnim.animateTo(0f, spring(0.8f, Spring.StiffnessLow)) }
}
}
Box(
modifier = Modifier
.offset(x = slideAnim.value.dp)
.alpha(alphaAnim.value)
) {
SharedRecentItemRow(item = item, onChatSelected = onChatSelected)
}
}
}
}
}
//endregion
//region --- Shared Item Components ---
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SharedRecentItemRow(
item: RecentMessage,
onChatSelected: (RecentMessage) -> Unit
) {
// Safe Scope Access: We render the item even if shared transitions aren't active.
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val cardColor = Color(0xFF2B2939)
// Custom interaction source to disable ripple effect
val interactionSource = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(cardColor, RoundedCornerShape(18.dp))
.clickable(
interactionSource = interactionSource,
indication = null // Ripple Removed
) { onChatSelected(item) }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// AVATAR (Shared Element)
Box(
modifier = Modifier
.size(50.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFF3E3C4E))
.then(
if (sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(key = "avatar-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> playfulSpring }
)
}
} else Modifier
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = item.icon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
// NAME (Shared Bounds + ScaleToBounds)
Text(
text = item.name,
style = TextStyle(
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
maxLines = 1,
modifier = Modifier.then(
if (sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = "name-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> playfulSpring },
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(
ContentScale.Fit,
Alignment.CenterStart
)
)
}
} else Modifier
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = item.message,
style = TextStyle(color = Color.White.copy(0.6f), fontSize = 14.sp),
maxLines = 1
)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.height(40.dp)
) {
Text(
text = item.time,
style = TextStyle(color = Color.White.copy(0.4f), fontSize = 12.sp)
)
if (item.isOnline) {
Box(
modifier = Modifier
.size(10.dp)
.background(Color(0xFF2ED3B7), CircleShape)
)
}
}
}
}
@Composable
fun DraggableFavoriteItemShared(
item: RecentMessage,
onDelete: () -> Unit,
onChatSelected: (RecentMessage) -> Unit
) {
val density = LocalDensity.current
val revealSizeDp = 100.dp
val maxRevealPx = with(density) { -revealSizeDp.toPx() }
val snapThreshold = maxRevealPx / 2
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd
) {
// Reveal Background
Box(
modifier = Modifier
.width(revealSizeDp)
.height(72.dp),
contentAlignment = Alignment.Center
) {
val progress = (offsetX.value / maxRevealPx).coerceIn(0f, 1.2f)
Box(
modifier = Modifier
.size(72.dp)
.scale(progress)
.clip(RoundedCornerShape(18.dp))
.background(Color(0xFFFF4D86))
.clickable { onDelete() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Delete",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
// Draggable Content
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
val newVal = (offsetX.value + delta).coerceIn(maxRevealPx * 1.5f, 0f)
scope.launch { offsetX.snapTo(newVal) }
},
onDragStopped = {
val targetOffset = if (offsetX.value < snapThreshold) maxRevealPx else 0f
scope.launch {
offsetX.animateTo(
targetValue = targetOffset,
animationSpec = spring(
Spring.DampingRatioMediumBouncy,
Spring.StiffnessLow
)
)
}
}
)
) {
SharedRecentItemRow(item = item, onChatSelected = onChatSelected)
}
}
}
@Composable
fun MessageBubble(msg: ChatMessage, index: Int) {
val startOffsetX = if (msg.isFromMe) 200f else -200f
val slideAnim = remember { Animatable(startOffsetX) }
val alphaAnim = remember { Animatable(0f) }
LaunchedEffect(Unit) {
// Delay helps content appear after shared transition settles
delay(index * 100L + 300L)
launch {
slideAnim.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = 0.7f,
stiffness = Spring.StiffnessLow
)
)
}
launch {
alphaAnim.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 400)
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationX = slideAnim.value
alpha = alphaAnim.value
},
contentAlignment = if (msg.isFromMe) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start) {
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(
if (msg.isFromMe) RoundedCornerShape(20.dp, 20.dp, 4.dp, 20.dp)
else RoundedCornerShape(20.dp, 20.dp, 20.dp, 4.dp)
)
.background(if (msg.isFromMe) Color(0xFF6C63FF) else Color(0xFF3E3C4E))
.padding(16.dp)
) {
Text(
text = msg.text,
color = Color.White,
fontSize = 16.sp
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = msg.time,
color = Color.White.copy(0.4f),
fontSize = 12.sp
)
}
}
}
//endregion
//region --- Utils & Bottom Bar ---
fun getUltraSmoothedEdgesShape(
flareWidth: Float,
flareHeight: Float,
cornerSize: Float,
hasStartFlare: Boolean,
hasEndFlare: Boolean
) = GenericShape { size, _ ->
val fw = flareWidth
val fh = flareHeight
val cs = cornerSize
val w = size.width
val h = size.height
if (hasStartFlare) {
moveTo(0f, 0f)
cubicTo(fw * 0.8f, 0f, fw, fh * 0.4f, fw, fh)
lineTo(fw, h - cs)
} else {
moveTo(0f, 0f)
lineTo(0f, h - cs)
}
val lx = if (hasStartFlare) fw else 0f
cubicTo(lx, h - (cs * 0.4f), lx + (cs * 0.4f), h, lx + cs, h)
val rx = w - (if (hasEndFlare) fw else 0f)
lineTo(rx - cs, h)
cubicTo(rx - (cs * 0.4f), h, rx, h - (cs * 0.4f), rx, h - cs)
if (hasEndFlare) {
lineTo(rx, fh)
cubicTo(rx, fh * 0.4f, rx + (fw * 0.2f), 0f, w, 0f)
} else {
lineTo(w, 0f)
}
close()
}
@Composable
fun BottomNavBar(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.shadow(20.dp, RoundedCornerShape(36.dp), spotColor = Color.Black.copy(0.6f))
.clip(RoundedCornerShape(36.dp))
.background(Color(0xFF2B2939).copy(alpha = 0.95f))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(36.dp)),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF6C63FF)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Rounded.ChatBubble,
contentDescription = "Chat",
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
Icon(
imageVector = Icons.Outlined.Call,
contentDescription = "Call",
tint = Color.White.copy(0.4f),
modifier = Modifier.size(28.dp)
)
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = "Profile",
tint = Color.White.copy(0.4f),
modifier = Modifier.size(28.dp)
)
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "Settings",
tint = Color.White.copy(0.4f),
modifier = Modifier.size(28.dp)
)
}
}
}
}
fun Float.toDp(density: Density): Dp = with(density) { this@toDp.toDp() }
@Preview
@Composable
fun AppPreview() {
AppNavigation()
}
//endregion
// --- Dummy Data ---
val dummyRecents = listOf(
RecentMessage(
1,
"Max Hall",
"Hello Friend! How are you?",
"08:30 pm",
true,
Icons.Rounded.Person
),
RecentMessage(2, "Dan Martin", "Hi man! Do you know?...", "04:12 pm", true, Icons.Rounded.Face),
RecentMessage(
3,
"Stephen Green",
"Yes! I like it!",
"02:05 pm",
true,
Icons.Rounded.EmojiEmotions
),
RecentMessage(
4,
"Sarah Woodman",
"How about my work?",
"Yesterday",
false,
Icons.Rounded.SentimentSatisfied
),
RecentMessage(5, "Peter Hopper", "At 5 pm", "01.22.201", false, Icons.Rounded.AccountCircle),
RecentMessage(
6,
"Denis Ivanov",
"Oh, no! Are you sure?",
"01.16.201",
false,
Icons.Rounded.SupervisedUserCircle
),
RecentMessage(7, "Alice Silver", "Hello Alex!", "01.12.201", false, Icons.Rounded.Face),
)
val dummyFavorites = listOf(
RecentMessage(
4,
"Sarah Woodman",
"How about my work?",
"Yesterday",
false,
Icons.Rounded.SentimentSatisfied
),
RecentMessage(5, "Peter Hopper", "At 5 pm", "01.22.201", false, Icons.Rounded.AccountCircle),
RecentMessage(
6,
"Denis Ivanov",
"Oh, no! Are you sure?",
"01.16.201",
false,
Icons.Rounded.SupervisedUserCircle
),
RecentMessage(7, "Alice Silver", "Hello Alex!", "01.12.201", false, Icons.Rounded.Face),
)
val dummyGroups = listOf(
RecentMessage(
10,
"Design Team",
"New mockups are ready!",
"10:30 am",
true,
Icons.Rounded.Brush
),
RecentMessage(
11,
"Weekend Trip",
"Who is bringing the snacks?",
"09:15 am",
true,
Icons.Rounded.DirectionsCar
),
RecentMessage(
12,
"Family Group",
"Mom: Call me when you can",
"Yesterday",
false,
Icons.Rounded.Home
),
RecentMessage(13, "Project Alpha", "Meeting delayed to 4 PM", "Mon", true, Icons.Rounded.Work),
RecentMessage(14, "Gaming Squad", "Online tonight?", "Sun", false, Icons.Rounded.SportsEsports),
)
val chatDummyData = listOf(
ChatMessage(1, "Hello Frank! How are you?", false, "12:30"),
ChatMessage(2, "Hello I'm fine. Thanks! And you?", true, "12:28"),
ChatMessage(3, "Fine! I have a question", false, "12:30"),
ChatMessage(4, "Question?", true, "12:28"),
ChatMessage(5, "How about my work?", false, "12:30"),
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment