Created
June 14, 2026 22:40
-
-
Save TuleSimon/29d717a5f35ec3ee78f111589b5215c5 to your computer and use it in GitHub Desktop.
AnimatedPager
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
| package com.simon.animatedbooksample | |
| import android.os.Bundle | |
| import androidx.activity.compose.BackHandler | |
| import androidx.activity.ComponentActivity | |
| import androidx.activity.compose.setContent | |
| import androidx.activity.enableEdgeToEdge | |
| import androidx.compose.animation.AnimatedContent | |
| import androidx.compose.animation.AnimatedVisibilityScope | |
| import androidx.compose.animation.EnterExitState | |
| import androidx.compose.animation.ExperimentalSharedTransitionApi | |
| import androidx.compose.animation.SharedTransitionLayout | |
| import androidx.compose.animation.SharedTransitionScope | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.CubicBezierEasing | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.LinearEasing | |
| import androidx.compose.animation.core.LinearOutSlowInEasing | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateFloat | |
| 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.togetherWith | |
| import androidx.compose.foundation.Image | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.gestures.awaitEachGesture | |
| import androidx.compose.foundation.gestures.awaitFirstDown | |
| import androidx.compose.foundation.gestures.drag | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.BoxScope | |
| import androidx.compose.foundation.layout.BoxWithConstraints | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.WindowInsets | |
| import androidx.compose.foundation.layout.aspectRatio | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.fillMaxHeight | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.navigationBarsPadding | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.statusBarsPadding | |
| import androidx.compose.foundation.layout.systemBars | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.layout.windowInsetsPadding | |
| import androidx.compose.foundation.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.outlined.AccountCircle | |
| import androidx.compose.material.icons.outlined.BookmarkBorder | |
| import androidx.compose.material.icons.outlined.Close | |
| import androidx.compose.material.icons.outlined.FileDownload | |
| import androidx.compose.material.icons.outlined.FavoriteBorder | |
| import androidx.compose.material.icons.outlined.Home | |
| import androidx.compose.material.icons.outlined.NorthEast | |
| import androidx.compose.material.icons.outlined.QrCodeScanner | |
| import androidx.compose.material.icons.outlined.Search | |
| import androidx.compose.material.icons.outlined.ShoppingBag | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.Surface | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.Immutable | |
| import androidx.compose.runtime.derivedStateOf | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableFloatStateOf | |
| import androidx.compose.runtime.mutableIntStateOf | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.rememberUpdatedState | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.blur | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.graphics.Brush | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.TransformOrigin | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.input.pointer.positionChange | |
| import androidx.compose.ui.input.pointer.util.VelocityTracker | |
| import androidx.compose.ui.layout.ContentScale | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.res.painterResource | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import androidx.compose.ui.util.lerp | |
| import androidx.compose.ui.zIndex | |
| import com.simon.animatedbooksample.ui.theme.AnimatedBookSampleTheme | |
| import kotlinx.coroutines.coroutineScope | |
| import kotlinx.coroutines.launch | |
| import kotlin.math.PI | |
| import kotlin.math.absoluteValue | |
| import kotlin.math.sign | |
| import kotlin.math.sin | |
| @Immutable | |
| private data class Magazine( | |
| val id: Int, | |
| val coverRes: Int, | |
| val number: String, | |
| val title: String, | |
| val date: String, | |
| val credit: String, | |
| val description: String, | |
| val accent: Color, | |
| ) | |
| private val Magazines = listOf( | |
| Magazine( | |
| id = 1, | |
| coverRes = R.drawable.cover_alek, | |
| number = "01", | |
| title = "ON THE PLASTICITY\nOF AN ACTOR", | |
| date = "30 JULY", | |
| credit = "Alek by Mohamed Bourouissa", | |
| description = "A stripped-back portrait study on presence, texture, and control.", | |
| accent = Color(0xFFE8C9B6), | |
| ), | |
| Magazine( | |
| id = 2, | |
| coverRes = R.drawable.cover_rihanna, | |
| number = "02", | |
| title = "THE WARDROBE OF\nSUPERMODELS", | |
| date = "23 JULY", | |
| credit = "Rihanna by Harley Weir", | |
| description = "Nocturnal styling, soft red light, and an intimate fashion language.", | |
| accent = Color(0xFF8B2A2A), | |
| ), | |
| Magazine( | |
| id = 3, | |
| coverRes = R.drawable.cover_malika, | |
| number = "03", | |
| title = "NEW ARCHETYPES", | |
| date = "16 JULY", | |
| credit = "Malika by Bilal El Kadhi", | |
| description = "New silhouettes and close portraiture framed with quiet confidence.", | |
| accent = Color(0xFFEFE6DE), | |
| ), | |
| Magazine( | |
| id = 4, | |
| coverRes = R.drawable.cover_zayn, | |
| number = "04", | |
| title = "FEEL THE HEAT", | |
| date = "09 JULY", | |
| credit = "Zayn for DAZED", | |
| description = "A direct studio cover built around leather, contrast, and attitude.", | |
| accent = Color(0xFFF3F3F3), | |
| ), | |
| Magazine( | |
| id = 5, | |
| coverRes = R.drawable.cover_damson, | |
| number = "05", | |
| title = "THE CELEBRATION\nISSUE", | |
| date = "02 JULY", | |
| credit = "Damson Idris", | |
| description = "Warm tones, relaxed posture, and a celebratory editorial frame.", | |
| accent = Color(0xFFD9743A), | |
| ), | |
| Magazine( | |
| id = 6, | |
| coverRes = R.drawable.cover_harry, | |
| number = "06", | |
| title = "A MOMENT,\nPLEASE", | |
| date = "25 JUNE", | |
| credit = "Harry Styles", | |
| description = "A cool-toned cover focused on stillness, gaze, and close detail.", | |
| accent = Color(0xFF3D3D3D), | |
| ), | |
| Magazine( | |
| id = 7, | |
| coverRes = R.drawable.cover_olivia, | |
| number = "07", | |
| title = "RED DRESS\nBLUE SKY", | |
| date = "18 JUNE", | |
| credit = "Olivia Rodrigo", | |
| description = "A sharp pop portrait with saturated color and a bright studio edge.", | |
| accent = Color(0xFF2B71A7), | |
| ), | |
| Magazine( | |
| id = 8, | |
| coverRes = R.drawable.cover_naomi, | |
| number = "08", | |
| title = "THE BUTTERFLY\nEFFECT", | |
| date = "11 JUNE", | |
| credit = "Naomi Campbell", | |
| description = "High drama styling, winged silhouette, and a direct iconic pose.", | |
| accent = Color(0xFFE49A35), | |
| ), | |
| Magazine( | |
| id = 9, | |
| coverRes = R.drawable.cover_white_dress, | |
| number = "09", | |
| title = "THE NEW\nCEREMONY", | |
| date = "04 JUNE", | |
| credit = "Dazed Spring Cover", | |
| description = "A clean blue set, white tailoring, and ceremonial movement.", | |
| accent = Color(0xFF1E7C9E), | |
| ), | |
| Magazine( | |
| id = 10, | |
| coverRes = R.drawable.cover_smile, | |
| number = "10", | |
| title = "SMILE!", | |
| date = "28 MAY", | |
| credit = "Beauty Special", | |
| description = "A vivid beauty cover built from painted color and graphic attitude.", | |
| accent = Color(0xFFF4D6C4), | |
| ), | |
| Magazine( | |
| id = 11, | |
| coverRes = R.drawable.cover_fka_twigs, | |
| number = "11", | |
| title = "FUTURE\nRITUALS", | |
| date = "21 MAY", | |
| credit = "FKA twigs", | |
| description = "An editorial portrait with theatrical hair, shadow, and restraint.", | |
| accent = Color(0xFF2D2D2F), | |
| ), | |
| Magazine( | |
| id = 12, | |
| coverRes = R.drawable.cover_dream, | |
| number = "12", | |
| title = "DREAM\nSTATE", | |
| date = "14 MAY", | |
| credit = "Dream Cover", | |
| description = "Red hair, boxing gloves, and a surreal combat-styled frame.", | |
| accent = Color(0xFFB5262E), | |
| ), | |
| Magazine( | |
| id = 13, | |
| coverRes = R.drawable.cover_yellow_boy, | |
| number = "13", | |
| title = "SOFT\nELECTRIC", | |
| date = "07 MAY", | |
| credit = "Yellow Cover", | |
| description = "A muted suit portrait cut against a loud yellow masthead.", | |
| accent = Color(0xFFE4D21E), | |
| ), | |
| Magazine( | |
| id = 14, | |
| coverRes = R.drawable.cover_bella, | |
| number = "14", | |
| title = "LIGHT\nTOUCH", | |
| date = "30 APRIL", | |
| credit = "Bella Hadid", | |
| description = "A pale studio composition with quiet styling and soft contrast.", | |
| accent = Color(0xFFEAE6E2), | |
| ), | |
| Magazine( | |
| id = 15, | |
| coverRes = R.drawable.cover_pink_portrait, | |
| number = "15", | |
| title = "CLOSE\nFORM", | |
| date = "23 APRIL", | |
| credit = "Pink Portrait", | |
| description = "A close profile study with soft light and a restrained cover crop.", | |
| accent = Color(0xFFE2D1CC), | |
| ), | |
| ) | |
| private val HomeThumbnails = Magazines.drop(1).take(3) | |
| private sealed interface Screen { | |
| data object Home : Screen | |
| data class Detail(val issueIndex: Int) : Screen | |
| } | |
| @Immutable | |
| private data class StackIndices( | |
| val backRight: Int, | |
| val backLeft: Int, | |
| val hidden: Int, | |
| ) | |
| @Immutable | |
| private data class DetailRenderState( | |
| val issue: Magazine, | |
| val swipeDirection: Int, | |
| val incomingIndex: Int, | |
| val incomingIssue: Magazine, | |
| val transitionAmount: Float, | |
| ) | |
| @Immutable | |
| private data class DetailTurnMetrics( | |
| val amount: Float, | |
| val reveal: Float, | |
| val behindTuck: Float, | |
| val incomingIndex: Int, | |
| val direction: Float, | |
| val incomingSide: Float, | |
| val doorArc: Float, | |
| val isBehindIncoming: Boolean, | |
| ) | |
| private fun detailRenderState( | |
| currentIndex: Int, | |
| swipeProgress: Float, | |
| ): DetailRenderState { | |
| val swipeDirection = when { | |
| swipeProgress < -0.01f -> -1 | |
| swipeProgress > 0.01f -> 1 | |
| else -> 0 | |
| } | |
| val incomingIndex = when { | |
| swipeDirection < 0 -> (currentIndex + 1) % Magazines.size | |
| swipeDirection > 0 -> (currentIndex - 1 + Magazines.size) % Magazines.size | |
| else -> currentIndex | |
| } | |
| return DetailRenderState( | |
| issue = Magazines[currentIndex], | |
| swipeDirection = swipeDirection, | |
| incomingIndex = incomingIndex, | |
| incomingIssue = Magazines[incomingIndex], | |
| transitionAmount = (swipeProgress.absoluteValue * 2f).coerceIn(0f, 1f), | |
| ) | |
| } | |
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| enableEdgeToEdge() | |
| setContent { | |
| AnimatedBookSampleTheme(dynamicColor = false) { | |
| Surface(color = Color.Black, modifier = Modifier.fillMaxSize()) { | |
| MogazApp() | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| private fun MogazApp() { | |
| var screen by remember { mutableStateOf<Screen>(Screen.Home) } | |
| BackHandler(enabled = screen is Screen.Detail) { | |
| screen = Screen.Home | |
| } | |
| SharedTransitionLayout { | |
| AnimatedContent( | |
| targetState = screen, | |
| transitionSpec = { | |
| fadeIn(tween(260)) togetherWith fadeOut(tween(220)) | |
| }, | |
| label = "Mogaz screen", | |
| ) { target -> | |
| when (target) { | |
| Screen.Home -> HomeScreen( | |
| sharedTransitionScope = this@SharedTransitionLayout, | |
| animatedVisibilityScope = this, | |
| onIssueClick = { issueIndex -> | |
| screen = Screen.Detail(issueIndex) | |
| }, | |
| ) | |
| is Screen.Detail -> DetailScreen( | |
| initialIndex = target.issueIndex, | |
| sharedTransitionScope = this@SharedTransitionLayout, | |
| animatedVisibilityScope = this, | |
| onClose = { screen = Screen.Home }, | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| private fun HomeScreen( | |
| sharedTransitionScope: SharedTransitionScope, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onIssueClick: (Int) -> Unit, | |
| ) { | |
| BoxWithConstraints( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Black) | |
| .windowInsetsPadding(WindowInsets.systemBars) | |
| ) { | |
| val sheetHeight = this.maxHeight * 0.4f | |
| val cardTop = minOf(maxHeight * 0.265f, 222.dp) | |
| WhiteContentSheet( | |
| modifier = Modifier | |
| .align(Alignment.BottomCenter) | |
| .fillMaxWidth() | |
| .height(sheetHeight) | |
| ) | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 32.dp) | |
| .padding(top = 42.dp), | |
| ) { | |
| TopBar() | |
| Spacer(Modifier.height(24.dp)) | |
| SearchRow() | |
| } | |
| CardStack( | |
| sharedTransitionScope = sharedTransitionScope, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| onIssueClick = onIssueClick, | |
| modifier = Modifier | |
| .align(Alignment.TopCenter) | |
| .fillMaxWidth() | |
| .padding(top = cardTop) | |
| .height(440.dp) | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun TopBar() { | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 4.dp), | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| Box( | |
| modifier = Modifier | |
| .size(18.dp) | |
| .clip(CircleShape) | |
| .background(Color(0xFF10E6B4)) | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .size(18.dp) | |
| .clip(CircleShape) | |
| .background(Color(0xFFFF0A61)) | |
| .graphicsLayer { translationX = (-7).dp.toPx() } | |
| ) | |
| Spacer(Modifier.width(0.dp)) | |
| Text( | |
| text = "Mogaz", | |
| color = Color.White, | |
| fontSize = 32.sp, | |
| fontWeight = FontWeight.Black, | |
| ) | |
| } | |
| Spacer(Modifier.weight(1f)) | |
| Icon( | |
| imageVector = Icons.Outlined.QrCodeScanner, | |
| contentDescription = "Scan", | |
| tint = Color.White, | |
| modifier = Modifier.size(30.dp) | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun SearchRow() { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(44.dp) | |
| .clip(RoundedCornerShape(24.dp)) | |
| .background(Color.White.copy(alpha = 0.09f)) | |
| .padding(horizontal = 18.dp), | |
| contentAlignment = Alignment.CenterStart, | |
| ) { | |
| Icon( | |
| imageVector = Icons.Outlined.Search, | |
| contentDescription = "Search", | |
| tint = Color.White, | |
| modifier = Modifier.size(28.dp) | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun WhiteContentSheet(modifier: Modifier = Modifier) { | |
| Column( | |
| modifier = modifier | |
| .clip(RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp)) | |
| .background(Color(0xFFFFFBFF)) | |
| .padding(top = 118.dp), | |
| ) { | |
| AllMagazinesSection() | |
| Spacer(Modifier.weight(1f)) | |
| BottomNav() | |
| } | |
| } | |
| @Immutable | |
| private data class StackSlot( | |
| val scale: Float, | |
| val tx: Dp, | |
| val ty: Dp, | |
| val rotation: Float, | |
| val pivotX: Float = 0.5f, | |
| val pivotY: Float = 0.92f, | |
| val alpha: Float = 1f, | |
| ) | |
| private val SlotTop = StackSlot(scale = 1.08f, tx = 0.dp, ty = 0.dp, rotation = 0f) | |
| private val SlotBackRight = StackSlot( | |
| scale = 0.98f, | |
| tx = 40.dp, | |
| ty = 42.dp, | |
| rotation = 10f, | |
| pivotX = 0.32f, | |
| pivotY = 0.92f, | |
| ) | |
| private val SlotBackLeft = StackSlot( | |
| scale = 0.98f, | |
| tx = (-40).dp, | |
| ty = 42.dp, | |
| rotation = -10f, | |
| pivotX = 0.68f, | |
| pivotY = 0.92f, | |
| ) | |
| private val SlotHidden = StackSlot( | |
| scale = 0.7f, | |
| tx = 0.dp, | |
| ty = 74.dp, | |
| rotation = -7f, | |
| pivotY = 0.9f, | |
| alpha = 0f, | |
| ) | |
| private fun lerpSlot(a: StackSlot, b: StackSlot, t: Float): StackSlot { | |
| val tt = t.coerceIn(0f, 1f) | |
| return StackSlot( | |
| scale = lerp(a.scale, b.scale, tt), | |
| tx = a.tx + (b.tx - a.tx) * tt, | |
| ty = a.ty + (b.ty - a.ty) * tt, | |
| rotation = lerp(a.rotation, b.rotation, tt), | |
| pivotX = lerp(a.pivotX, b.pivotX, tt), | |
| pivotY = lerp(a.pivotY, b.pivotY, tt), | |
| alpha = lerp(a.alpha, b.alpha, tt), | |
| ) | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| private fun CardStack( | |
| sharedTransitionScope: SharedTransitionScope, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onIssueClick: (Int) -> Unit, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| val n = Magazines.size | |
| val latestOnIssueClick by rememberUpdatedState(onIssueClick) | |
| var topIndex by remember { mutableIntStateOf(0) } | |
| val scope = rememberCoroutineScope() | |
| val density = LocalDensity.current | |
| var dragX by remember { mutableFloatStateOf(0f) } | |
| var dragY by remember { mutableFloatStateOf(0f) } | |
| var dragRot by remember { mutableFloatStateOf(0f) } | |
| val exitAlpha = remember { Animatable(1f) } | |
| val promotion = remember { Animatable(0f) } | |
| var isExiting by remember { mutableStateOf(false) } | |
| BoxWithConstraints( | |
| modifier = modifier | |
| .fillMaxWidth() | |
| .padding(top = 4.dp), | |
| contentAlignment = Alignment.TopCenter, | |
| ) { | |
| val widthPx = remember(density, this.maxWidth) { with(density) { maxWidth.toPx() } } | |
| val heightPx = remember(density, maxHeight) { with(density) { maxHeight.toPx() } } | |
| val cardWidth = remember(maxWidth) { minOf(maxWidth * 0.63f, 252.dp) } | |
| val stackIndices by remember(n) { | |
| derivedStateOf { | |
| StackIndices( | |
| backRight = (topIndex + 1) % n, | |
| backLeft = (topIndex + 2) % n, | |
| hidden = (topIndex + 3) % n, | |
| ) | |
| } | |
| } | |
| val dragReveal by remember(widthPx) { | |
| derivedStateOf { | |
| (dragX.absoluteValue / (widthPx * 0.24f)).coerceIn(0f, 1f) * 0.72f | |
| } | |
| } | |
| val p = maxOf(promotion.value, dragReveal) | |
| StackedCard( | |
| Magazines[stackIndices.hidden], | |
| lerpSlot(SlotHidden, SlotBackLeft, p), | |
| cardWidth | |
| ) | |
| StackedCard( | |
| Magazines[stackIndices.backLeft], | |
| lerpSlot(SlotBackLeft, SlotBackRight, p), | |
| cardWidth | |
| ) | |
| StackedCard( | |
| Magazines[stackIndices.backRight], | |
| lerpSlot(SlotBackRight, SlotTop, p), | |
| cardWidth | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .graphicsLayer { | |
| val slot = SlotTop | |
| translationX = slot.tx.toPx() + dragX | |
| translationY = slot.ty.toPx() + dragY | |
| rotationZ = slot.rotation + dragRot | |
| alpha = exitAlpha.value | |
| scaleX = slot.scale | |
| scaleY = slot.scale | |
| transformOrigin = TransformOrigin(slot.pivotX, slot.pivotY) | |
| } | |
| .pointerInput(topIndex, widthPx, heightPx) { | |
| if (isExiting) return@pointerInput | |
| awaitEachGesture { | |
| val down = awaitFirstDown(requireUnconsumed = false) | |
| val tracker = VelocityTracker() | |
| tracker.addPosition(down.uptimeMillis, down.position) | |
| drag(down.id) { change -> | |
| tracker.addPosition(change.uptimeMillis, change.position) | |
| val delta = change.positionChange() | |
| dragX += delta.x | |
| dragY += delta.y | |
| dragRot = ((dragX / widthPx) * 34f).coerceIn(-16f, 16f) | |
| change.consume() | |
| } | |
| val velocity = tracker.calculateVelocity() | |
| val distance = dragX | |
| val distanceThreshold = widthPx * 0.22f | |
| val velocityThreshold = 700f | |
| val pastDist = distance.absoluteValue > distanceThreshold | |
| val pastVel = velocity.x.absoluteValue > velocityThreshold | |
| if (pastDist || pastVel) { | |
| val dir = if (pastDist) sign(distance) else sign(velocity.x) | |
| scope.launch { | |
| isExiting = true | |
| runFlick( | |
| direction = dir, | |
| widthPx = widthPx, | |
| heightPx = heightPx, | |
| startX = dragX, | |
| startY = dragY, | |
| startRot = dragRot, | |
| setDragX = { dragX = it }, | |
| setDragY = { dragY = it }, | |
| setDragRot = { dragRot = it }, | |
| exitAlpha = exitAlpha, | |
| promotion = promotion, | |
| ) | |
| topIndex = (topIndex + 1) % n | |
| dragX = 0f; dragY = 0f; dragRot = 0f | |
| exitAlpha.snapTo(1f) | |
| promotion.snapTo(0f) | |
| isExiting = false | |
| } | |
| } else { | |
| scope.launch { | |
| springBack( | |
| startX = dragX, | |
| startY = dragY, | |
| startRot = dragRot, | |
| setDragX = { dragX = it }, | |
| setDragY = { dragY = it }, | |
| setDragRot = { dragRot = it }, | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| ) { | |
| val topMagazine by remember { | |
| derivedStateOf { Magazines[topIndex] } | |
| } | |
| val sharedModifier = with(sharedTransitionScope) { | |
| Modifier.sharedElement( | |
| state = rememberSharedContentState(key = "cover-${topMagazine.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| ) | |
| } | |
| CoverCard( | |
| magazine = topMagazine, | |
| modifier = sharedModifier | |
| .width(cardWidth) | |
| .clickable { latestOnIssueClick(topIndex) }, | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun StackedCard(magazine: Magazine, slot: StackSlot, cardWidth: Dp) { | |
| Box( | |
| modifier = Modifier.graphicsLayer { | |
| translationX = slot.tx.toPx() | |
| translationY = slot.ty.toPx() | |
| rotationZ = slot.rotation | |
| scaleX = slot.scale | |
| scaleY = slot.scale | |
| alpha = slot.alpha | |
| transformOrigin = TransformOrigin(slot.pivotX, slot.pivotY) | |
| } | |
| ) { | |
| CoverCard( | |
| magazine = magazine, | |
| modifier = Modifier.width(cardWidth), | |
| ) | |
| } | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| private fun DetailScreen( | |
| initialIndex: Int, | |
| sharedTransitionScope: SharedTransitionScope, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onClose: () -> Unit, | |
| ) { | |
| var currentIndex by remember(initialIndex) { mutableIntStateOf(initialIndex) } | |
| val swipeProgressState = remember { mutableFloatStateOf(0f) } | |
| val swipeProgressProvider: () -> Float = remember { | |
| { swipeProgressState.floatValue } | |
| } | |
| val updateSwipeProgress: (Float) -> Unit = remember { | |
| { progress -> swipeProgressState.floatValue = progress } | |
| } | |
| val updateCurrentIndex: (Int) -> Unit = remember { | |
| { index -> currentIndex = index } | |
| } | |
| val issue = Magazines[currentIndex] | |
| val detailGradient = remember(issue.accent) { | |
| Brush.verticalGradient( | |
| colors = listOf( | |
| issue.accent.copy(alpha = 0.20f), | |
| Color.Black.copy(alpha = 0.18f), | |
| Color.Black.copy(alpha = 0.72f), | |
| Color.Black, | |
| ) | |
| ) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Black) | |
| ) { | |
| DetailBackdrop( | |
| currentIndex = currentIndex, | |
| swipeProgressProvider = swipeProgressProvider, | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(detailGradient) | |
| ) | |
| DetailTopBar( | |
| modifier = Modifier | |
| .align(Alignment.TopCenter) | |
| .fillMaxWidth() | |
| .statusBarsPadding() | |
| .padding(horizontal = 22.dp, vertical = 16.dp), | |
| onClose = onClose, | |
| ) | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .statusBarsPadding() | |
| .padding(top = 58.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| ) { | |
| DetailCoverPager( | |
| currentIndex = currentIndex, | |
| sharedTransitionScope = sharedTransitionScope, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| onProgressChange = updateSwipeProgress, | |
| onIndexChange = updateCurrentIndex, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(380.dp), | |
| ) | |
| Spacer(Modifier.height(8.dp)) | |
| DetailIssueActions( | |
| currentIndex = currentIndex, | |
| swipeProgressProvider = swipeProgressProvider, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 38.dp), | |
| ) | |
| Spacer(Modifier.height(14.dp)) | |
| AnimatedDetailInfoPanel( | |
| currentIndex = currentIndex, | |
| swipeProgressProvider = swipeProgressProvider, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .weight(1f) | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun rememberDetailRenderState( | |
| currentIndex: Int, | |
| swipeProgressProvider: () -> Float, | |
| ): DetailRenderState { | |
| val state by remember(currentIndex, swipeProgressProvider) { | |
| derivedStateOf { | |
| detailRenderState( | |
| currentIndex = currentIndex, | |
| swipeProgress = swipeProgressProvider(), | |
| ) | |
| } | |
| } | |
| return state | |
| } | |
| @Composable | |
| private fun DetailBackdrop( | |
| currentIndex: Int, | |
| swipeProgressProvider: () -> Float, | |
| ) { | |
| val detailState = rememberDetailRenderState( | |
| currentIndex = currentIndex, | |
| swipeProgressProvider = swipeProgressProvider, | |
| ) | |
| BlurredIssueBackground( | |
| currentIssue = detailState.issue, | |
| incomingIssue = detailState.incomingIssue, | |
| incomingAlpha = detailState.transitionAmount, | |
| ) | |
| } | |
| @Composable | |
| private fun DetailIssueActions( | |
| currentIndex: Int, | |
| swipeProgressProvider: () -> Float, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| val detailState = rememberDetailRenderState( | |
| currentIndex = currentIndex, | |
| swipeProgressProvider = swipeProgressProvider, | |
| ) | |
| Row( | |
| modifier = modifier, | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| IssueDateTransition( | |
| currentIssue = detailState.issue, | |
| incomingIssue = detailState.incomingIssue, | |
| incomingAlpha = detailState.transitionAmount, | |
| modifier = Modifier.width(120.dp), | |
| ) | |
| Spacer(Modifier.weight(1f)) | |
| Icon( | |
| imageVector = Icons.Outlined.FavoriteBorder, | |
| contentDescription = "Like", | |
| tint = Color.White, | |
| modifier = Modifier.size(24.dp) | |
| ) | |
| Spacer(Modifier.width(18.dp)) | |
| Icon( | |
| imageVector = Icons.Outlined.BookmarkBorder, | |
| contentDescription = "Bookmark", | |
| tint = Color.White, | |
| modifier = Modifier.size(24.dp) | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun AnimatedDetailInfoPanel( | |
| currentIndex: Int, | |
| swipeProgressProvider: () -> Float, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| val detailState = rememberDetailRenderState( | |
| currentIndex = currentIndex, | |
| swipeProgressProvider = swipeProgressProvider, | |
| ) | |
| val panelEntrance by animatedVisibilityScope.transition.animateFloat( | |
| transitionSpec = { | |
| tween( | |
| durationMillis = if (targetState == EnterExitState.Visible) 420 else 260, | |
| delayMillis = if (targetState == EnterExitState.Visible) 120 else 0, | |
| easing = FastOutSlowInEasing, | |
| ) | |
| }, | |
| label = "detail panel entrance", | |
| ) { state -> | |
| if (state == EnterExitState.Visible) 1f else 0f | |
| } | |
| val density = LocalDensity.current | |
| val panelEntranceOffsetPx = remember(density) { with(density) { 34.dp.toPx() } } | |
| DetailInfoPanel( | |
| currentIssue = detailState.issue, | |
| incomingIssue = detailState.incomingIssue, | |
| incomingAlpha = detailState.transitionAmount, | |
| direction = detailState.swipeDirection, | |
| modifier = modifier.graphicsLayer { | |
| alpha = panelEntrance | |
| translationY = panelEntranceOffsetPx * (1f - panelEntrance) | |
| } | |
| ) | |
| } | |
| @Composable | |
| private fun BlurredIssueBackground( | |
| currentIssue: Magazine, | |
| incomingIssue: Magazine, | |
| incomingAlpha: Float, | |
| ) { | |
| IssueBackdropImage( | |
| issue = currentIssue, | |
| alpha = 0.58f * (1f - incomingAlpha), | |
| ) | |
| IssueBackdropImage( | |
| issue = incomingIssue, | |
| alpha = 0.58f * incomingAlpha, | |
| ) | |
| } | |
| @Composable | |
| private fun IssueBackdropImage( | |
| issue: Magazine, | |
| alpha: Float, | |
| ) { | |
| Image( | |
| painter = painterResource(id = issue.coverRes), | |
| contentDescription = null, | |
| contentScale = ContentScale.Crop, | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { | |
| scaleX = 1.16f | |
| scaleY = 1.16f | |
| this.alpha = alpha | |
| } | |
| .blur(30.dp), | |
| ) | |
| } | |
| @Composable | |
| private fun IssueDateTransition( | |
| currentIssue: Magazine, | |
| incomingIssue: Magazine, | |
| incomingAlpha: Float, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| val outgoingAlpha = (1f - incomingAlpha * 2.15f).coerceIn(0f, 1f) | |
| val incomingTextAlpha = ((incomingAlpha - 0.30f) / 0.42f).coerceIn(0f, 1f) | |
| Box(modifier = modifier.height(18.dp), contentAlignment = Alignment.CenterStart) { | |
| Text( | |
| text = currentIssue.date, | |
| color = Color.White, | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Bold, | |
| modifier = Modifier.graphicsLayer { | |
| alpha = outgoingAlpha | |
| translationY = -8f * (1f - outgoingAlpha) | |
| }, | |
| ) | |
| Text( | |
| text = incomingIssue.date, | |
| color = Color.White, | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Bold, | |
| modifier = Modifier.graphicsLayer { | |
| alpha = incomingTextAlpha | |
| translationY = 8f * (1f - incomingTextAlpha) | |
| }, | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun DetailTopBar( | |
| modifier: Modifier = Modifier, | |
| onClose: () -> Unit, | |
| ) { | |
| val latestOnClose by rememberUpdatedState(onClose) | |
| Row( | |
| modifier = modifier, | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| Icon( | |
| imageVector = Icons.Outlined.Close, | |
| contentDescription = "Close", | |
| tint = Color.White, | |
| modifier = Modifier | |
| .size(26.dp) | |
| .clickable { latestOnClose() } | |
| ) | |
| Spacer(Modifier.weight(1f)) | |
| Icon( | |
| imageVector = Icons.Outlined.NorthEast, | |
| contentDescription = "Open", | |
| tint = Color.White, | |
| modifier = Modifier.size(24.dp) | |
| ) | |
| } | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| private fun DetailCoverPager( | |
| currentIndex: Int, | |
| sharedTransitionScope: SharedTransitionScope, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onProgressChange: (Float) -> Unit, | |
| onIndexChange: (Int) -> Unit, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| val n = Magazines.size | |
| val latestOnProgressChange by rememberUpdatedState(onProgressChange) | |
| val latestOnIndexChange by rememberUpdatedState(onIndexChange) | |
| val scope = rememberCoroutineScope() | |
| val density = LocalDensity.current | |
| var dragX by remember { mutableFloatStateOf(0f) } | |
| var isTurning by remember { mutableStateOf(false) } | |
| BoxWithConstraints( | |
| modifier = modifier.pointerInput(currentIndex) { | |
| if (isTurning) return@pointerInput | |
| awaitEachGesture { | |
| val down = awaitFirstDown(requireUnconsumed = false) | |
| drag(down.id) { change -> | |
| dragX += change.positionChange().x | |
| latestOnProgressChange((dragX / size.width).coerceIn(-1f, 1f)) | |
| change.consume() | |
| } | |
| val threshold = size.width * 0.16f | |
| if (dragX.absoluteValue > threshold) { | |
| val direction = sign(dragX) | |
| scope.launch { | |
| isTurning = true | |
| val endX = direction * size.width | |
| val a = Animatable(dragX) | |
| a.animateTo(endX, tween(360, easing = FastOutSlowInEasing)) { | |
| dragX = value | |
| latestOnProgressChange((value / size.width).coerceIn(-1f, 1f)) | |
| } | |
| val nextIndex = if (direction < 0f) { | |
| (currentIndex + 1) % n | |
| } else { | |
| (currentIndex - 1 + n) % n | |
| } | |
| latestOnIndexChange(nextIndex) | |
| dragX = 0f | |
| latestOnProgressChange(0f) | |
| isTurning = false | |
| } | |
| } else { | |
| scope.launch { | |
| val a = Animatable(dragX) | |
| a.animateTo( | |
| 0f, | |
| spring(dampingRatio = 0.68f, stiffness = Spring.StiffnessMediumLow), | |
| ) { | |
| dragX = value | |
| latestOnProgressChange((value / size.width).coerceIn(-1f, 1f)) | |
| } | |
| latestOnProgressChange(0f) | |
| } | |
| } | |
| } | |
| }, | |
| contentAlignment = Alignment.Center, | |
| ) { | |
| val widthPx = remember(density, this.maxWidth) { with(density) { maxWidth.toPx() } } | |
| val cardWidth = remember(maxWidth) { minOf(maxWidth * 0.62f, 252.dp) } | |
| val liftPx = remember(density) { with(density) { 38.dp.toPx() } } | |
| val turnMetrics by remember(widthPx, currentIndex, n) { | |
| derivedStateOf { | |
| val progress = (dragX / widthPx).coerceIn(-1f, 1f) | |
| val amount = progress.absoluteValue | |
| val reveal = (amount * 2f).coerceIn(0f, 1f) | |
| val direction = when { | |
| progress < -0.01f -> -1f | |
| progress > 0.01f -> 1f | |
| else -> -1f | |
| } | |
| DetailTurnMetrics( | |
| amount = amount, | |
| reveal = reveal, | |
| behindTuck = ((amount - 0.60f) / 0.40f).coerceIn(0f, 1f), | |
| incomingIndex = if (progress <= 0f) { | |
| (currentIndex + 1) % n | |
| } else { | |
| (currentIndex - 1 + n) % n | |
| }, | |
| direction = direction, | |
| incomingSide = -direction, | |
| doorArc = sin((amount * PI).toFloat()).coerceAtLeast(0f), | |
| isBehindIncoming = amount >= 0.58f, | |
| ) | |
| } | |
| } | |
| val amount = turnMetrics.amount | |
| val reveal = turnMetrics.reveal | |
| val behindTuck = turnMetrics.behindTuck | |
| val incomingIndex = turnMetrics.incomingIndex | |
| val direction = turnMetrics.direction | |
| val incomingSide = turnMetrics.incomingSide | |
| val doorArc = turnMetrics.doorArc | |
| val isBehindIncoming = turnMetrics.isBehindIncoming | |
| if (amount > 0.01f || isTurning) { | |
| DetailCoverLayer( | |
| magazine = Magazines[incomingIndex], | |
| cardWidth = cardWidth, | |
| translationX = incomingSide * widthPx * (0.10f * (1f - reveal)), | |
| translationY = liftPx * 0.16f * (1f - reveal), | |
| rotationY = incomingSide * lerp(132f, 0f, reveal), | |
| rotationZ = incomingSide * lerp(4f, 0f, reveal), | |
| scale = lerp(0.9f, 1f, reveal), | |
| alpha = 1f, | |
| hingeX = if (incomingSide > 0f) 0f else 1f, | |
| turnAmount = 1f - reveal, | |
| z = if (isBehindIncoming) 4f else 1f, | |
| sharedTransitionScope = null, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| ) | |
| } | |
| DetailCoverLayer( | |
| magazine = Magazines[currentIndex], | |
| cardWidth = cardWidth, | |
| translationX = (direction * widthPx * 0.18f * doorArc) + | |
| (-direction * widthPx * 0.42f * behindTuck), | |
| translationY = (-liftPx * 0.74f * doorArc) + (liftPx * 0.16f * behindTuck), | |
| rotationY = direction * 180f * amount, | |
| rotationZ = direction * 7f * doorArc, | |
| scale = 1f - amount * 0.10f, | |
| alpha = 1f, | |
| hingeX = if (direction < 0f) 0f else 1f, | |
| turnAmount = amount, | |
| z = if (isBehindIncoming) 0f else 5f, | |
| sharedTransitionScope = sharedTransitionScope, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| ) | |
| } | |
| } | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| private fun DetailCoverLayer( | |
| magazine: Magazine, | |
| cardWidth: Dp, | |
| translationX: Float, | |
| translationY: Float, | |
| rotationY: Float, | |
| rotationZ: Float, | |
| scale: Float, | |
| alpha: Float, | |
| hingeX: Float, | |
| turnAmount: Float, | |
| z: Float, | |
| sharedTransitionScope: SharedTransitionScope?, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| ) { | |
| val density = LocalDensity.current | |
| val sharedModifier = if (sharedTransitionScope != null) { | |
| with(sharedTransitionScope) { | |
| Modifier.sharedElement( | |
| state = rememberSharedContentState(key = "cover-${magazine.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| ) | |
| } | |
| } else { | |
| Modifier | |
| } | |
| val depthAmount = remember(turnAmount) { | |
| sin((turnAmount.coerceIn(0f, 1f) * PI).toFloat()).coerceAtLeast(0f) | |
| } | |
| Box( | |
| modifier = sharedModifier | |
| .width(cardWidth) | |
| .aspectRatio(0.72f) | |
| .graphicsLayer { | |
| this.translationX = translationX | |
| this.translationY = translationY | |
| this.rotationY = rotationY | |
| this.rotationZ = rotationZ | |
| this.scaleX = scale | |
| this.scaleY = scale | |
| this.alpha = alpha | |
| cameraDistance = 28f * density.density | |
| transformOrigin = TransformOrigin(hingeX, 0.5f) | |
| } | |
| .zIndex(z) | |
| ) { | |
| if (turnAmount > 0.04f) { | |
| val primaryIsStart = hingeX <= 0.5f | |
| val secondaryIsStart = !primaryIsStart | |
| val primaryWidth = lerp(24f, 84f, depthAmount).dp | |
| val secondaryWidth = lerp(12f, 46f, depthAmount).dp | |
| val primaryShiftPx = with(density) { | |
| (primaryWidth.value * if (primaryIsStart) -0.58f else 0.58f).dp.toPx() | |
| } | |
| val secondaryShiftPx = with(density) { | |
| (secondaryWidth.value * if (secondaryIsStart) -0.40f else 0.40f).dp.toPx() | |
| } | |
| CardThicknessEdge( | |
| width = primaryWidth, | |
| alignment = if (primaryIsStart) Alignment.CenterStart else Alignment.CenterEnd, | |
| translationX = primaryShiftPx, | |
| startEdge = primaryIsStart, | |
| alpha = 1f, | |
| ) | |
| CardThicknessEdge( | |
| width = secondaryWidth, | |
| alignment = if (secondaryIsStart) Alignment.CenterStart else Alignment.CenterEnd, | |
| translationX = secondaryShiftPx, | |
| startEdge = secondaryIsStart, | |
| alpha = 0.78f, | |
| ) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .clip(RoundedCornerShape(4.dp)) | |
| .background(Color.Black) | |
| ) { | |
| Image( | |
| painter = painterResource(id = magazine.coverRes), | |
| contentDescription = magazine.title, | |
| contentScale = ContentScale.Crop, | |
| modifier = Modifier.fillMaxSize(), | |
| ) | |
| if (turnAmount > 0.50f) { | |
| val backAlpha = ((turnAmount - 0.50f) / 0.08f).coerceIn(0f, 1f) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color(0xFFF3EEE9).copy(alpha = backAlpha * 0.94f)) | |
| .padding(14.dp) | |
| ) { | |
| Text( | |
| text = "DAZED", | |
| color = Color.Black.copy(alpha = backAlpha), | |
| fontSize = 30.sp, | |
| fontWeight = FontWeight.Black, | |
| modifier = Modifier.align(Alignment.TopStart), | |
| ) | |
| Text( | |
| text = magazine.date, | |
| color = Color.Black.copy(alpha = backAlpha * 0.72f), | |
| fontSize = 9.sp, | |
| fontWeight = FontWeight.Bold, | |
| modifier = Modifier.align(Alignment.BottomStart), | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun BoxScope.CardThicknessEdge( | |
| width: Dp, | |
| alignment: Alignment, | |
| translationX: Float, | |
| startEdge: Boolean, | |
| alpha: Float, | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .align(alignment) | |
| .fillMaxHeight() | |
| .width(width) | |
| .graphicsLayer { | |
| this.translationX = translationX | |
| this.alpha = alpha | |
| } | |
| .background( | |
| Brush.horizontalGradient( | |
| colors = if (startEdge) { | |
| listOf( | |
| Color.White.copy(alpha = 0.98f), | |
| Color(0xFFEDE4DC).copy(alpha = 0.94f), | |
| Color(0xFF9D9389).copy(alpha = 0.80f), | |
| Color.Black.copy(alpha = 0.42f), | |
| ) | |
| } else { | |
| listOf( | |
| Color.Black.copy(alpha = 0.42f), | |
| Color(0xFF9D9389).copy(alpha = 0.80f), | |
| Color(0xFFEDE4DC).copy(alpha = 0.94f), | |
| Color.White.copy(alpha = 0.98f), | |
| ) | |
| }, | |
| ) | |
| ) | |
| ) | |
| } | |
| @Composable | |
| private fun DetailInfoPanel( | |
| currentIssue: Magazine, | |
| incomingIssue: Magazine, | |
| incomingAlpha: Float, | |
| direction: Int, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| Box( | |
| modifier = modifier | |
| .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) | |
| .background(Color.Black.copy(alpha = 0.72f)) | |
| .navigationBarsPadding() | |
| .padding(horizontal = 28.dp, vertical = 20.dp) | |
| ) { | |
| val motionDirection = if (direction == 0) 1 else direction.coerceIn(-1, 1) | |
| val outgoingAlpha = (1f - incomingAlpha * 2.15f).coerceIn(0f, 1f) | |
| val incomingTextAlpha = ((incomingAlpha - 0.30f) / 0.42f).coerceIn(0f, 1f) | |
| val outgoingOffset = -24f * (1f - outgoingAlpha) * motionDirection | |
| val incomingOffset = 28f * (1f - incomingTextAlpha) * motionDirection | |
| DetailInfoContent( | |
| issue = currentIssue, | |
| alpha = outgoingAlpha, | |
| translationX = outgoingOffset, | |
| ) | |
| DetailInfoContent( | |
| issue = incomingIssue, | |
| alpha = incomingTextAlpha, | |
| translationX = incomingOffset, | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun DetailInfoContent( | |
| issue: Magazine, | |
| alpha: Float, | |
| translationX: Float, | |
| ) { | |
| Column( | |
| modifier = Modifier.graphicsLayer { | |
| this.alpha = alpha | |
| this.translationX = translationX | |
| } | |
| ) { | |
| Text( | |
| text = issue.number, | |
| color = Color.White, | |
| fontSize = 116.sp, | |
| lineHeight = 104.sp, | |
| fontWeight = FontWeight.Black, | |
| ) | |
| Text( | |
| text = issue.title, | |
| color = Color.White, | |
| fontSize = 16.sp, | |
| lineHeight = 18.sp, | |
| fontWeight = FontWeight.Black, | |
| ) | |
| Spacer(Modifier.height(8.dp)) | |
| Text( | |
| text = issue.credit, | |
| color = Color.White.copy(alpha = 0.72f), | |
| fontSize = 10.sp, | |
| fontWeight = FontWeight.SemiBold, | |
| ) | |
| Spacer(Modifier.height(8.dp)) | |
| Text( | |
| text = issue.description, | |
| color = Color.White.copy(alpha = 0.54f), | |
| fontSize = 10.sp, | |
| lineHeight = 13.sp, | |
| ) | |
| } | |
| } | |
| private suspend fun runFlick( | |
| direction: Float, | |
| widthPx: Float, | |
| heightPx: Float, | |
| startX: Float, | |
| startY: Float, | |
| startRot: Float, | |
| setDragX: (Float) -> Unit, | |
| setDragY: (Float) -> Unit, | |
| setDragRot: (Float) -> Unit, | |
| exitAlpha: Animatable<Float, *>, | |
| promotion: Animatable<Float, *>, | |
| ) = coroutineScope { | |
| val duration = 720 | |
| val targetX = direction * widthPx * 1.7f | |
| val targetY = heightPx * 1.15f | |
| val targetRot = direction * 55f | |
| val decelerate = LinearOutSlowInEasing | |
| val gravity = CubicBezierEasing(0.55f, 0.0f, 0.95f, 0.55f) | |
| launch { | |
| val a = Animatable(startX) | |
| a.animateTo(targetX, tween(duration, easing = decelerate)) { setDragX(value) } | |
| } | |
| launch { | |
| val a = Animatable(startY) | |
| a.animateTo(targetY, tween(duration, easing = gravity)) { setDragY(value) } | |
| } | |
| launch { | |
| val a = Animatable(startRot) | |
| a.animateTo(targetRot, tween(duration, easing = LinearEasing)) { setDragRot(value) } | |
| } | |
| launch { | |
| exitAlpha.snapTo(1f) | |
| exitAlpha.animateTo( | |
| 0f, | |
| tween( | |
| durationMillis = (duration * 0.45f).toInt(), | |
| delayMillis = (duration * 0.55f).toInt(), | |
| easing = LinearEasing | |
| ), | |
| ) | |
| } | |
| launch { | |
| promotion.snapTo(0f) | |
| promotion.animateTo(1f, tween(duration, easing = FastOutSlowInEasing)) | |
| } | |
| } | |
| private suspend fun springBack( | |
| startX: Float, | |
| startY: Float, | |
| startRot: Float, | |
| setDragX: (Float) -> Unit, | |
| setDragY: (Float) -> Unit, | |
| setDragRot: (Float) -> Unit, | |
| ) = coroutineScope { | |
| val spec = spring<Float>(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow) | |
| launch { | |
| val a = Animatable(startX) | |
| a.animateTo(0f, spec) { setDragX(value) } | |
| } | |
| launch { | |
| val a = Animatable(startY) | |
| a.animateTo(0f, spec) { setDragY(value) } | |
| } | |
| launch { | |
| val a = Animatable(startRot) | |
| a.animateTo(0f, spec) { setDragRot(value) } | |
| } | |
| } | |
| @Composable | |
| private fun CoverCard( | |
| magazine: Magazine, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| Box( | |
| modifier = modifier | |
| .aspectRatio(0.72f) | |
| .clip(RoundedCornerShape(3.dp)) | |
| .background(Color.Black), | |
| ) { | |
| Image( | |
| painter = painterResource(id = magazine.coverRes), | |
| contentDescription = magazine.title, | |
| contentScale = ContentScale.Crop, | |
| modifier = Modifier.fillMaxSize(), | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun AllMagazinesSection() { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 40.dp), | |
| ) { | |
| Text( | |
| text = "All magazines", | |
| color = Color.Black, | |
| fontSize = 20.sp, | |
| fontWeight = FontWeight.Black, | |
| ) | |
| Spacer(Modifier.height(22.dp)) | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(22.dp), | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| HomeThumbnails.forEach { mag -> | |
| Box( | |
| modifier = Modifier | |
| .weight(1f) | |
| .aspectRatio(1f) | |
| .clip(RoundedCornerShape(2.dp)) | |
| .background(Color.Black), | |
| ) { | |
| Image( | |
| painter = painterResource(id = mag.coverRes), | |
| contentDescription = mag.title, | |
| contentScale = ContentScale.Crop, | |
| modifier = Modifier.fillMaxSize(), | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun BottomNav() { | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 42.dp) | |
| .padding(top = 18.dp, bottom = 16.dp), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| Icon( | |
| imageVector = Icons.Outlined.Home, | |
| contentDescription = "Home", | |
| tint = Color.Black, | |
| modifier = Modifier.size(32.dp) | |
| ) | |
| Icon( | |
| imageVector = Icons.Outlined.ShoppingBag, | |
| contentDescription = "Bag", | |
| tint = Color.Black, | |
| modifier = Modifier.size(32.dp) | |
| ) | |
| Icon( | |
| imageVector = Icons.Outlined.FileDownload, | |
| contentDescription = "Downloads", | |
| tint = Color.Black, | |
| modifier = Modifier.size(34.dp) | |
| ) | |
| Icon( | |
| imageVector = Icons.Outlined.AccountCircle, | |
| contentDescription = "Profile", | |
| tint = Color.Black, | |
| modifier = Modifier.size(34.dp) | |
| ) | |
| } | |
| } | |
| @Preview(showBackground = true, widthDp = 390, heightDp = 844) | |
| @Composable | |
| private fun HomePreview() { | |
| AnimatedBookSampleTheme(dynamicColor = false) { | |
| Surface(color = Color.Black) { MogazApp() } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment