Skip to content

Instantly share code, notes, and snippets.

@JunkFood02
Last active May 24, 2024 21:31
Show Gist options
  • Save JunkFood02/caf2af3cee41f847c0ad0bcf4d0cf9d8 to your computer and use it in GitHub Desktop.
Save JunkFood02/caf2af3cee41f847c0ad0bcf4d0cf9d8 to your computer and use it in GitHub Desktop.
A music player demo made with Jetpack Compose animation APIs, including shared element transition, list animations, animated content, etc.
package com.example.compose_debug
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.PathEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.ui.graphics.Path
// Material 3 Emphasized Easing
// https://m3.material.io/styles/motion/easing-and-duration/tokens-specs
const val DURATION = 600
const val DURATION_ENTER = 400
const val DURATION_ENTER_SHORT = 300
const val DURATION_EXIT = 200
const val DURATION_EXIT_SHORT = 100
private val emphasizedPath = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
}
val EmphasizedEasing: Easing = PathEasing(emphasizedPath)
val EmphasizedDecelerateEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
val EmphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
public fun materialSharedAxisXIn(
initialOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = DURATION_ENTER_SHORT,
): EnterTransition = slideInHorizontally(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
initialOffsetX = initialOffsetX
) + fadeIn(
animationSpec = tween(
durationMillis = durationMillis.ForIncoming,
delayMillis = durationMillis.ForOutgoing,
easing = LinearOutSlowInEasing
)
)
public fun materialSharedAxisXOut(
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = DURATION_ENTER_SHORT,
): ExitTransition = slideOutHorizontally(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
targetOffsetX = targetOffsetX
) + fadeOut(
animationSpec = tween(
durationMillis = durationMillis.ForOutgoing,
delayMillis = 0,
easing = FastOutLinearInEasing
)
)
private const val ProgressThreshold = 0.35f
private val Int.ForOutgoing: Int
get() = (this * ProgressThreshold).toInt()
private val Int.ForIncoming: Int
get() = this - this.ForOutgoing
@file:OptIn(
ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class,
ExperimentalAnimationApi::class
)
package com.example.compose_debug
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.DeferredTargetAnimation
import androidx.compose.animation.core.ExperimentalAnimatableApi
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
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.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Repeat
import androidx.compose.material.icons.rounded.ArrowBackIosNew
import androidx.compose.material.icons.rounded.FastForward
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SliderState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.approachLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.example.compose_debug.ui.theme.ComposeDebugTheme
data class Track(
val id: Int,
val title: String,
val artist: String,
val resId: Int,
val albumTitle: String,
)
val tracks = listOf(
"Without You Without Them",
"$20",
"Emily I'm Sorry",
"True Blue",
"Cool About It",
"Not Strong Enough",
"Revolution 0",
"Leonard Cohen",
"Satanist",
"We're In Love",
"Anti-Curse",
"Letter To An Old Poet",
)
val tracks2 = listOf(
"Dynasty",
"XS",
"STFU!",
"Comme Des Garçons (Like The Boys)",
"Akasaka Sad",
"Paradisin'",
"Love Me 4 Me",
"Bad Friend",
"Fuck This World (Interlude)",
"Who's Gonna Save U Now?",
"Tokyo Love Hotel",
"Chosen Family",
"Snakeskin"
)
val TrackList = buildList {
var index = 0
tracks.forEach {
add(
Track(
id = index,
title = it,
artist = "boygenius",
albumTitle = "the record",
resId = R.drawable.artwork1
)
)
index++
}
tracks2.forEach {
add(
Track(
id = index,
title = it,
artist = "Rina Sawayama",
albumTitle = "SAWAYAMA",
resId = R.drawable.artwork2
)
)
index++
}
}
private val SmallArtSize = 48.dp
private val MediumArtSize = 72.dp
private val HorizontalPaddingDp = 16.dp
private val AlbumArtBoundsTransform = BoundsTransform { _, _ ->
tween(easing = EmphasizedEasing, durationMillis = DURATION)
}
private fun <T> tweenEnter(
delayMillis: Int = DURATION_EXIT,
durationMillis: Int = DURATION_ENTER
) =
tween<T>(
delayMillis = delayMillis,
durationMillis = durationMillis,
easing = EmphasizedDecelerateEasing
)
private fun <T> tweenExit(
durationMillis: Int = DURATION_EXIT_SHORT,
) = tween<T>(
durationMillis = durationMillis,
easing = EmphasizedAccelerateEasing
)
const val FULL_PLAYER = 0
const val PLAY_QUEUE = 1
const val MINI_PLAYER = 2
@Composable
@Preview
fun PlayerTransformDemo() {
var show by remember {
mutableIntStateOf(FULL_PLAYER)
}
var nowPlaying by remember {
mutableStateOf(TrackList[0])
}
val playNextList = TrackList.subList(TrackList.indexOf(nowPlaying) + 1, TrackList.size)
val offset = with(LocalDensity.current) { (MediumArtSize.toPx()).toInt() }
ComposeDebugTheme {
SharedTransitionLayout(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
AnimatedContent(targetState = show, label = "", transitionSpec = {
fadeIn(
tweenEnter(delayMillis = DURATION_EXIT_SHORT)
) togetherWith fadeOut(
tweenExit(durationMillis = DURATION_EXIT_SHORT)
)
}) {
when (it) {
PLAY_QUEUE -> {
PlayerQueue(
imageModifier = { track ->
Modifier.sharedElement(
state = rememberSharedContentState(
key = track
),
boundsTransform = AlbumArtBoundsTransform,
animatedVisibilityScope = this,
placeHolderSize = { contentSize: IntSize, animatedSize: IntSize ->
IntSize(contentSize.width, animatedSize.height)
},
)
},
textModifier = Modifier,
nowPlaying = nowPlaying,
playNextList = playNextList,
onBackPressed = { show = FULL_PLAYER }
) { track ->
nowPlaying = track
}
}
FULL_PLAYER -> {
PlayerView(
nowPlaying = nowPlaying,
imageModifier = Modifier.sharedElement(
state = rememberSharedContentState(
key = nowPlaying
),
animatedVisibilityScope = this,
placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize,
boundsTransform = AlbumArtBoundsTransform,
),
containerModifier = Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(
key = "container"
),
animatedVisibilityScope = this,
placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize,
boundsTransform = AlbumArtBoundsTransform,
enter = fadeIn(
tweenEnter(delayMillis = DURATION_EXIT_SHORT)
),
exit = fadeOut(
tweenExit(durationMillis = DURATION_EXIT_SHORT)
)
),
onBackPressed = {
show = MINI_PLAYER
}
) {
show = PLAY_QUEUE
}
}
else -> {
MiniPlayer(
nowPlaying = nowPlaying,
onNextClicked = {
nowPlaying = playNextList.firstOrNull() ?: TrackList.first()
},
onClick = { show = FULL_PLAYER },
imageModifier = Modifier.sharedElement(
state = rememberSharedContentState(
key = nowPlaying
),
animatedVisibilityScope = this,
placeHolderSize = { contentSize: IntSize, animatedSize: IntSize ->
IntSize(contentSize.width, animatedSize.height)
},
boundsTransform = AlbumArtBoundsTransform,
),
containerModifier = Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(
key = "container"
),
animatedVisibilityScope = this,
placeHolderSize = SharedTransitionScope.PlaceHolderSize.contentSize,
boundsTransform = AlbumArtBoundsTransform,
enter = fadeIn(
tweenEnter(delayMillis = DURATION_EXIT)
),
exit = fadeOut(
tweenExit(durationMillis = DURATION_EXIT_SHORT)
)
)
)
}
}
}
}
}
}
@Composable
fun MiniPlayer(
nowPlaying: Track,
imageModifier: Modifier = Modifier,
containerModifier: Modifier = Modifier,
onNextClicked: () -> Unit = {},
onClick: () -> Unit = {},
) {
val transitionState = remember { MutableTransitionState(nowPlaying) }
LaunchedEffect(nowPlaying) {
transitionState.targetState = nowPlaying
}
val transition = rememberTransition(transitionState = transitionState)
val albumArtRedId = remember(transitionState.isIdle) {
transitionState.currentState.resId
}
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Surface(
modifier = containerModifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
onClick = onClick,
color = MaterialTheme.colorScheme.surfaceContainerHigh
) {
Row(
modifier = Modifier
.padding(vertical = 12.dp)
.navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(12.dp))
Image(
painter = painterResource(id = albumArtRedId),
contentDescription = null,
modifier = imageModifier
.size(SmallArtSize)
.clip(MaterialTheme.shapes.small),
)
Spacer(modifier = Modifier.width(8.dp))
Row(
modifier = Modifier.height(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
transition.AnimatedContent(
modifier = Modifier.weight(1f),
transitionSpec = {
ContentTransform(
materialSharedAxisXIn(initialOffsetX = { it / 10 }),
materialSharedAxisXOut(targetOffsetX = { -it / 10 }),
sizeTransform = SizeTransform(clip = true)
)
}
) {
Text(
text = it.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
)
}
FilledTonalIconButton(onClick = { /*TODO*/ }) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
}
IconButton(onClick = onNextClicked) {
Icon(imageVector = Icons.Rounded.FastForward, contentDescription = null)
}
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
@Composable
fun PlayerView(
nowPlaying: Track,
containerModifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onClick: () -> Unit = {}
) {
BackHandler {
onBackPressed()
}
Surface(
modifier = containerModifier.fillMaxHeight(),
color = MaterialTheme.colorScheme.surfaceContainer,
onClick = onClick
) {
Column(
modifier = Modifier.statusBarsPadding()
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp)
.padding(top = 12.dp, bottom = 12.dp)
) {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Rounded.ArrowBackIosNew,
contentDescription = null,
modifier = Modifier.rotate(-90f)
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onClick) {
Icon(imageVector = Icons.Rounded.MoreVert, contentDescription = null)
}
}
Image(
painter = painterResource(id = nowPlaying.resId),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
.then(imageModifier)
.clip(MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text(
text = nowPlaying.title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Medium)
)
Text(
text = nowPlaying.artist,
style = MaterialTheme.typography.bodyLarge,
)
}
Column(modifier = Modifier.padding(horizontal = 22.dp)) {
val sliderState = remember {
SliderState(0.73f)
}
val interactionSource = remember {
MutableInteractionSource()
}
val colors = SliderDefaults.colors()
Spacer(modifier = Modifier.height(16.dp))
Slider(
modifier = Modifier.height(20.dp),
state = sliderState,
colors = colors,
track = {
SliderDefaults.Track(
sliderState = sliderState,
drawStopIndicator = null,
thumbTrackGapSize = 4.dp,
modifier = Modifier.height(8.dp)
)
},
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
thumbSize = DpSize(width = 4.dp, height = 20.dp)
)
}, interactionSource = interactionSource
)
Row(modifier = Modifier.padding(horizontal = 2.dp)) {
Text(
text = "00:59",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "01:21",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlayerPreview() {
ComposeDebugTheme {
PlayerView(nowPlaying = TrackList[0])
}
}
@Composable
fun PlayerQueue(
imageModifier: @Composable (Track) -> Modifier = { Modifier },
textModifier: Modifier = Modifier,
nowPlaying: Track,
playNextList: List<Track>,
playQueueSource: String = "random playlist",
onBackPressed: () -> Unit = {},
onClick: (Track) -> Unit,
) {
BackHandler {
onBackPressed()
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxHeight(),
) {
Column(modifier = Modifier
.clickable { onBackPressed() }
.statusBarsPadding()
) {
Text(
text = "Now playing",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = HorizontalPaddingDp)
.padding(bottom = 16.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = HorizontalPaddingDp)
) {
Image(
painter = painterResource(id = nowPlaying.resId),
contentDescription = null,
modifier = Modifier
.then(imageModifier(nowPlaying))
.size(MediumArtSize)
.clip(MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = textModifier.weight(1f)) {
Text(text = nowPlaying.title, style = MaterialTheme.typography.titleMedium)
Text(
text = nowPlaying.artist,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Outlined.MoreVert, contentDescription = null)
}
Spacer(modifier = Modifier.width(8.dp))
}
Row(
modifier = Modifier
.padding(top = 24.dp, bottom = 4.dp)
.padding(horizontal = HorizontalPaddingDp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Playing from %s".format(playQueueSource),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
)
IconToggleButton(
checked = false,
onCheckedChange = {},
modifier = Modifier.size(32.dp),
colors = IconButtonDefaults.iconToggleButtonColors(
checkedContainerColor = MaterialTheme.colorScheme.onSurfaceVariant,
checkedContentColor = MaterialTheme.colorScheme.surface
)
) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
IconToggleButton(
checked = true,
onCheckedChange = {},
modifier = Modifier.size(32.dp),
colors = IconButtonDefaults.filledIconToggleButtonColors(
checkedContainerColor = MaterialTheme.colorScheme.onSurfaceVariant,
checkedContentColor = MaterialTheme.colorScheme.surface
)
) {
Icon(
imageVector = Icons.Rounded.Repeat,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
}
LazyColumn(
modifier = Modifier
.fillMaxHeight(),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(items = playNextList, key = { it.id }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.animateItemPreview()
.clickable { onClick(it) }
.padding(vertical = 12.dp)
) {
Spacer(modifier = Modifier.width(HorizontalPaddingDp))
Image(
painter = painterResource(id = it.resId),
contentDescription = null,
modifier = Modifier
.size(SmallArtSize)
.clip(MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = it.title,
style = MaterialTheme.typography.titleSmall
)
Text(
text = it.artist,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun QueuePreview() {
ComposeDebugTheme {
PlayerQueue(
nowPlaying = TrackList[0],
playNextList = TrackList.subList(1, TrackList.size),
) {}
}
}
context (LazyItemScope)
fun Modifier.animateItemPreview() = composed {
if (!LocalInspectionMode.current)
Modifier.animateItem(
fadeInSpec = tween(durationMillis = DURATION_ENTER_SHORT),
fadeOutSpec = tween(durationMillis = DURATION_EXIT_SHORT)
)
else this
}
@Preview
@Composable
private fun MiniPlayerPreview() {
var index by remember { mutableIntStateOf(0) }
MiniPlayer(nowPlaying = TrackList[index], onNextClicked = { index++ })
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment