Skip to content

Instantly share code, notes, and snippets.

@TuleSimon
Created June 14, 2026 22:40
Show Gist options
  • Select an option

  • Save TuleSimon/29d717a5f35ec3ee78f111589b5215c5 to your computer and use it in GitHub Desktop.

Select an option

Save TuleSimon/29d717a5f35ec3ee78f111589b5215c5 to your computer and use it in GitHub Desktop.
AnimatedPager
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