This Day In History Jetpack Compose App - Part 2 - Shimmer Animation
Objective:
Continue work on the main screen of the app. Introduce composables to display different states of the main screen: Loading, Error, and Success.
Components
Add/Modify the following components:
- HistoryListItem.kt: Add - Basic building block for displaying each historical event
- HistoryListImage: Add - Helper composable to display the thumnail associated with each historical event
- HistoryScreen: Modify - Introduce LazyColumn that will display mock data via HistoryListItem
- HistoryViewModelMock: Modify - Add mock data
Code
Error Screen
Add HistoryScreenError.kt to the com.coroutines.thisdayinhistory.ui.screens.main package in the app module:
@Composable
fun HistoryScreenError(errorMessage: String) {
Box(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center)
) {
val errorMessageFormatter: String =
java.lang.String.format(
stringResource(R.string.error_message_prefix),
BidiFormatter.getInstance().unicodeWrap(errorMessage)
)
Text(errorMessageFormatter)
}
}
Loading Screen
Add HistoryScreenLoading.kt to the com.coroutines.thisdayinhistory.ui.screens.main package in the app module:
@Composable
fun HistoryScreenLoading(vm: IHistoryViewModel) {
Box(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center)
) {
val state = vm.uiState.collectAsStateWithLifecycle()
val categories = state.value.catsByLanguage.getCategories().values.toList()
val tabItemsPadding = 16.dp // if (vm.windowSize.widthSizeClass > WindowWidthSizeClass.Compact) 50.dp else 16.dp
HistoryViewCategoryTabs(
categories = categories,
state.value.selectedCategory,
onCategorySelected = vm::onCategoryChanged,
tabItemsPadding = tabItemsPadding,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
)
Box(contentAlignment = Alignment.Center) {
ShimmerAnimation()
Column() {
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransitionForBrushOffset")
val offset by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "brushOffsetFloatAnimation"
)
val brush = shaderBrush(offset)
Text(
modifier = Modifier
.fillMaxWidth(),
text = state.value.selectedDate.day.toString(),
style = TextStyle(
brush = brush,
fontFamily = Montserrat,
fontSize = 48.sp,
fontWeight = FontWeight.Normal,
lineHeight = 59.sp
),
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.fillMaxWidth(),
text = state.value.selectedDate.month,
style = TextStyle(
brush = brush,
fontFamily = JosefinSans,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 37.sp
),
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
private fun shaderBrush(offset: Float) = remember(offset) {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val widthOffset = size.width * offset
val heightOffset = size.height * offset
return LinearGradientShader(
colors = listOf(
Color.Black,
Color.Gray,
Color.Black,
Color.Black
),
from = Offset(widthOffset, heightOffset),
to = Offset(widthOffset + size.width, heightOffset + size.height),
tileMode = TileMode.Mirror
)
}
}
}
Shimmer Item
Add ShimmerItem - a Column composable containing spacer shaped like a rectangle, set the [background]'s [brush] with the brush receiving from [ShimmerAnimation] Composable.
package com.coroutines.thisdayinhistory.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
@Composable
fun ShimmerItem(
brush: Brush,
) {
Column(modifier = Modifier.padding(0.dp)) {
Spacer(
modifier = Modifier
.fillMaxWidth()
.size(1450.dp)
.background(brush = brush)
)
}
}
Shimmer Color Shades
Add ShimmerColorShades.kt - a list of colors that the shimmer animation will rotate thru.
package com.coroutines.thisdayinhistory.ui.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import kotlin.math.max
import kotlin.math.min
const val MinContrastOfPrimaryVsSurface = 2f
val MetallicSilver = Color(0xFFA8A9AD)
val GrayishBlack = Color(0xFF555152)
val ShimmerColorShades = listOf(
Color.LightGray.copy(0.0f),
Color.LightGray.copy(0.33f),
Color.LightGray.copy(0.0f)
)
fun Color.contrastAgainst(background: Color): Float {
val fg = if (alpha < 1f) compositeOver(background) else this
val fgLuminance = fg.luminance() + 0.05f
val bgLuminance = background.luminance() + 0.05f
return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance)
}
Shimmer Color Shades
Add ShimmerColorShades.kt - Create InfiniteTransition which holds child animation like [Transition] animations start running as soon as they enter the composition and do not stop unless they are removed.
package com.coroutines.thisdayinhistory.ui.components
package com.coroutines.thisdayinhistory.ui.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.tooling.preview.Preview
@-Composable
fun ShimmerAnimation() {
/*
Create InfiniteTransition
which holds child animation like [Transition]
animations start running as soon as they enter
the composition and do not stop unless they are removed
*/
val transition = rememberInfiniteTransition(label = "")
val translateAnim by transition.animateFloat(
/*
Specify animation positions,
initial Values 0F means it
starts from 0 position
*/
initialValue = 0f,
// targetValue = 1000f,
targetValue = 2000f,
animationSpec = infiniteRepeatable(
// Tween Animates between values over specified [durationMillis]
tween(durationMillis = 1000, easing = FastOutSlowInEasing),
RepeatMode.Reverse
),
label = ""
)
/*
Create a gradient using the list of colors
Use Linear Gradient for animating in any direction according to requirement
start=specifies the position to start with in cartesian like system Offset(10f,10f) means x(10,0) , y(0,10)
end = Animate the end position to give the shimmer effect using the transition created above
*/
val brush = Brush.linearGradient(
colors = ShimmerColorShades,
start = Offset(10f, 10f),
end = Offset(translateAnim, translateAnim)
)
ShimmerItem(brush = brush)
}
History Screen
Modify HistoryScreen.kt in the com.coroutines.thisdayinhistory.ui.viewmodels package in the app module:
Add AnimatedContent
to switch between different states and display appropriate screens.
@Composable
fun HistoryScreen(
modifier: Modifier = Modifier,
navController: NavController,
settingsViewModel : ISettingsViewModel
){
AppNavigationDrawerWithContent(
navController = navController,
settingsViewModel = settingsViewModel
) {
val viewModel = HistoryViewModelMock()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val dataRequestState = uiState.dataRequestState
val option = uiState.selectedCategory
val categories = uiState.catsByLanguage.getCategories().values.toList()
AnimatedContent(targetState = dataRequestState,
transitionSpec = {
if (dataRequestState == DataRequestState.CompletedSuccessfully(RequestCategory.Option)) {
(slideInHorizontally (animationSpec = tween(durationMillis = 300))
{ width -> width }
+ fadeIn()).togetherWith(slideOutHorizontally
{ width -> -width } + fadeOut())
} else {
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(200)) using
SizeTransform { initialSize, targetSize ->
if (targetState == DataRequestState.CompletedSuccessfully()) {
keyFramesToTargetState(targetSize)
} else {
keyFramesToNonTargetState(initialSize)
}
}
}
}, label = "") { targetState ->
when {
targetState is DataRequestState.Started
-> HistoryScreenLoading(viewModel)
targetState is DataRequestState.CompletedWithError
-> HistoryScreenError(targetState.errorMessage)
targetState is DataRequestState.CompletedSuccessfully
-> {
Column(Modifier.fillMaxWidth()) {
HistoryViewCategoryTabs(
categories = categories,
option,
onCategorySelected = viewModel::onCategoryChanged,
tabItemsPadding = 10.dp,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
)
val listState = rememberLazyListState()
val historyViewModel = HistoryViewModelMock()
val data = historyViewModel.historyData
LazyColumn(
state = listState,
contentPadding = PaddingValues(bottom = 65.dp),
verticalArrangement = Arrangement.Top
) {
item { Spacer(Modifier.height(8.dp)) }
items(data) { item: HistoricalEvent ->
HistoryListItem(
historyEvent = item,
onClick = {},
onImageClick = {},
onShare = {},
)
}
item { Spacer(Modifier.height(20.dp)) }
}
}
}
}
}
}
}
private fun keyFramesToNonTargetState(initialSize: IntSize) = keyframes {
IntSize(
initialSize.width / 5,
initialSize.height / 5
) at 60
durationMillis = 70
IntSize(
initialSize.width / 3,
initialSize.height / 3
) at 130
durationMillis = 70
IntSize(
initialSize.width / 2,
initialSize.height / 2
) at 150
durationMillis = 70
}
private fun keyFramesToTargetState(targetSize: IntSize) = keyframes {
// Expand horizontally first.
IntSize(0, 0) at 0
durationMillis = 50
IntSize(
targetSize.width / 5,
targetSize.height / 5
) at 60
durationMillis = 50
IntSize(
targetSize.width / 3,
targetSize.height / 3
) at 100
durationMillis = 50
IntSize(
targetSize.width / 2,
targetSize.height / 2
) at 150
durationMillis = 50
}