blob: 718ebf6c40fd5eeee9a7d284eced885121ef8978 [file] [log] [blame]
package com.airbnb.lottie.sample.compose.player
import android.util.Log
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.airbnb.lottie.ImageAssetDelegate
import com.airbnb.lottie.LottieComposition
import com.airbnb.lottie.compose.*
import com.airbnb.lottie.sample.compose.BuildConfig
import com.airbnb.lottie.sample.compose.R
import com.airbnb.lottie.sample.compose.composables.DebouncedCircularProgressIndicator
import com.airbnb.lottie.sample.compose.ui.Teal
import com.airbnb.lottie.sample.compose.utils.drawBottomBorder
import com.airbnb.lottie.sample.compose.utils.maybeBackground
import com.airbnb.lottie.sample.compose.utils.maybeDrawBorder
import com.airbnb.lottie.sample.compose.utils.toDummyBitmap
import kotlin.math.ceil
import kotlin.math.roundToInt
@Stable
class PlayerPageState {
var playMode by mutableStateOf(LottiePlayMode.Play)
var outlineMasksAndMattes by mutableStateOf(false)
var applyOpacityToLayers by mutableStateOf(false)
var enableMergePaths by mutableStateOf(false)
var focusMode by mutableStateOf(false)
var showWarningsDialog by mutableStateOf(false)
var borderToolbar by mutableStateOf(false)
var speedToolbar by mutableStateOf(false)
var backgroundColorToolbar by mutableStateOf(false)
}
@Composable
fun PlayerPage(
spec: LottieCompositionSpec,
animationBackgroundColor: Color? = null,
) {
val scaffoldState = rememberScaffoldState()
val state = remember { PlayerPageState() }
val failedMessage = stringResource(R.string.failed_to_load)
val okMessage = stringResource(R.string.ok)
val compositionResult = lottieComposition(spec)
LaunchedEffect(compositionResult.isFailure) {
if (!compositionResult.isFailure) return@LaunchedEffect
scaffoldState.snackbarHostState.showSnackbar(
message = failedMessage,
actionLabel = okMessage,
)
}
Scaffold(
scaffoldState = scaffoldState,
topBar = { PlayerPageTopAppBar(state, compositionResult.value) },
) {
PlayerPageContent(
state,
compositionResult.value,
compositionResult.isLoading,
animationBackgroundColor
)
}
if (state.showWarningsDialog) {
WarningDialog(warnings = compositionResult.value?.warnings ?: emptyList(), onDismiss = { state.showWarningsDialog = false })
}
}
@Composable
private fun ColumnScope.ExpandVisibility(
visible: Boolean,
content: @Composable () -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(),
exit = shrinkVertically()
) {
content()
}
}
@Composable
private fun PlayerPageTopAppBar(
state: PlayerPageState,
composition: LottieComposition?,
) {
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
TopAppBar(
title = {},
backgroundColor = Color.Transparent,
elevation = 0.dp,
navigationIcon = {
IconButton(
onClick = { backPressedDispatcher?.onBackPressed() },
) {
Icon(
Icons.Default.Close,
contentDescription = null
)
}
},
actions = {
if (composition?.warnings?.isNotEmpty() == true) {
IconButton(
onClick = { state.showWarningsDialog = true }
) {
Icon(
Icons.Filled.Warning,
tint = Color.Black,
contentDescription = null
)
}
}
IconButton(
onClick = { state.focusMode = !state.focusMode },
) {
Icon(
Icons.Filled.RemoveRedEye,
tint = if (state.focusMode) Teal else Color.Black,
contentDescription = null
)
}
}
)
}
@Composable
fun PlayerPageContent(
state: PlayerPageState,
composition: LottieComposition?,
isLoading: Boolean,
animationBackgroundColor: Color?,
) {
var backgroundColor by remember(animationBackgroundColor) { mutableStateOf(animationBackgroundColor) }
val dummyBitmapStrokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val imageAssetDelegate = remember(composition) {
if (composition?.images?.any { (_, asset) -> asset.hasBitmap() } == true) {
null
} else {
ImageAssetDelegate { if (it.hasBitmap()) null else it.toDummyBitmap(dummyBitmapStrokeWidth) }
}
}
val animationState = animateLottieComposition(
composition,
playMode = state.playMode,
iterations = Integer.MAX_VALUE,
)
LaunchedEffect(animationState.iteration, animationState.iterations) {
Log.d("Gabe", "Iteration ${animationState.iteration}/${animationState.iterations}")
}
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxHeight()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.maybeBackground(backgroundColor)
.fillMaxWidth()
) {
LottieAnimation(
composition,
animationState.value,
imageAssetDelegate = imageAssetDelegate,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
.maybeDrawBorder(state.borderToolbar)
)
if (isLoading) {
DebouncedCircularProgressIndicator(
color = Teal,
modifier = Modifier
.size(48.dp)
)
}
}
ExpandVisibility(state.speedToolbar && !state.focusMode) {
SpeedToolbar(animationState)
}
ExpandVisibility(!state.focusMode && state.backgroundColorToolbar) {
BackgroundColorToolbar(
animationBackgroundColor = animationBackgroundColor,
onColorChanged = { backgroundColor = it }
)
}
ExpandVisibility(!state.focusMode) {
PlayerControlsRow(composition, state, animationState)
}
ExpandVisibility(!state.focusMode) {
Toolbar(state)
}
}
}
@Composable
private fun PlayerControlsRow(
composition: LottieComposition?,
state: PlayerPageState,
animationState: LottieAnimationState,
) {
val totalTime = ((composition?.duration ?: 0L / animationState.speed) / 1000.0)
val totalTimeFormatted = ("%.1f").format(totalTime)
val progressFormatted = ("%.1f").format(animationState.value * totalTime)
val frame = composition?.getFrameForProgress(animationState.value)?.roundToInt() ?: 0
val durationFrames = ceil(composition?.durationFrames ?: 0f).roundToInt()
Box(
modifier = Modifier
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
contentAlignment = Alignment.Center
) {
IconButton(
onClick = { state.playMode = !state.playMode },
) {
Icon(
if (animationState.isPlaying) Icons.Filled.Pause
else Icons.Filled.PlayArrow,
contentDescription = null
)
}
Text(
"$frame/$durationFrames\n${progressFormatted}/$totalTimeFormatted",
style = TextStyle(fontSize = 8.sp),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 48.dp, bottom = 8.dp)
)
}
Slider(
value = animationState.value,
onValueChange = { animationState.value = it },
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
animationState.iterations = if (animationState.iterations == Integer.MAX_VALUE) 1 else Integer.MAX_VALUE
},
) {
Icon(
Icons.Filled.Repeat,
tint = if (animationState.iterations == Integer.MAX_VALUE) Teal else Color.Black,
contentDescription = null
)
}
}
Text(
BuildConfig.VERSION_NAME,
fontSize = 6.sp,
color = Color.Gray,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
)
}
}
@Composable
private fun SpeedToolbar(
animationState: LottieAnimationState,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.drawBottomBorder()
.padding(vertical = 12.dp, horizontal = 16.dp)
.fillMaxWidth()
) {
ToolbarChip(
label = "0.5x",
isActivated = animationState.speed == 0.5f,
onClick = { animationState.speed = 0.5f },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
label = "1x",
isActivated = animationState.speed == 1f,
onClick = { animationState.speed = 1f },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
label = "1.5x",
isActivated = animationState.speed == 1.5f,
onClick = { animationState.speed = 1.5f },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
label = "2x",
isActivated = animationState.speed == 2f,
onClick = { animationState.speed = 2f },
modifier = Modifier.padding(end = 8.dp)
)
}
}
@Composable
private fun BackgroundColorToolbar(
animationBackgroundColor: Color?,
onColorChanged: (Color) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.drawBottomBorder()
.padding(vertical = 12.dp, horizontal = 16.dp)
.fillMaxWidth()
) {
listOfNotNull(
colorResource(R.color.background_color1),
colorResource(R.color.background_color2),
colorResource(R.color.background_color3),
colorResource(R.color.background_color4),
colorResource(R.color.background_color5),
colorResource(R.color.background_color6),
animationBackgroundColor.takeIf { it != Color.White },
).forEachIndexed { i, color ->
val strokeColor = if (i == 0) colorResource(R.color.background_color1_stroke) else color
BackgroundToolbarItem(
color = color,
strokeColor = strokeColor,
onClick = { onColorChanged(color) }
)
}
}
}
@Composable
private fun BackgroundToolbarItem(
color: Color,
strokeColor: Color = color,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick)
.size(24.dp)
.border(1.dp, strokeColor, shape = CircleShape)
)
}
@Composable
private fun Toolbar(state: PlayerPageState) {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp)
.padding(bottom = 8.dp)
) {
ToolbarChip(
iconPainter = painterResource(R.drawable.ic_masks_and_mattes),
label = stringResource(R.string.toolbar_item_masks),
isActivated = state.outlineMasksAndMattes,
onClick = { state.outlineMasksAndMattes = it },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
iconPainter = painterResource(R.drawable.ic_layers),
label = stringResource(R.string.toolbar_item_opacity_layers),
isActivated = state.applyOpacityToLayers,
onClick = { state.applyOpacityToLayers = it },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
iconPainter = painterResource(R.drawable.ic_color),
label = stringResource(R.string.toolbar_item_color),
isActivated = state.backgroundColorToolbar,
onClick = { state.backgroundColorToolbar = it },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
iconPainter = painterResource(R.drawable.ic_speed),
label = stringResource(R.string.toolbar_item_speed),
isActivated = state.speedToolbar,
onClick = { state.speedToolbar = it },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
iconPainter = painterResource(R.drawable.ic_border),
label = stringResource(R.string.toolbar_item_border),
isActivated = state.borderToolbar,
onClick = { state.borderToolbar = it },
modifier = Modifier.padding(end = 8.dp)
)
ToolbarChip(
iconPainter = rememberVectorPainter(Icons.Default.MergeType),
label = stringResource(R.string.toolbar_item_merge_paths),
isActivated = state.enableMergePaths,
onClick = { state.enableMergePaths = it },
modifier = Modifier.padding(end = 8.dp)
)
}
}
@Composable
fun WarningDialog(
warnings: List<String>,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(4.dp),
modifier = Modifier
.width(400.dp)
.heightIn(min = 32.dp, max = 500.dp)
) {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
) {
LazyColumn {
itemsIndexed(warnings) { i, warning ->
Text(
warning,
fontSize = 8.sp,
textAlign = TextAlign.Left,
modifier = Modifier
.fillMaxWidth()
.run { if (i != 0) drawBottomBorder() else this }
.padding(vertical = 12.dp, horizontal = 16.dp)
)
}
}
}
}
}
}
@Preview
@Composable
fun SpeedToolbarPreview() {
val state = animateLottieComposition(null)
SpeedToolbar(state)
}
@Preview(name = "Player")
@Composable
fun PlayerPagePreview() {
PlayerPage(LottieCompositionSpec.Url("https://lottiefiles.com/download/public/32922"))
}