Compose : advanceTimeBy doesn't work with animation - android

I've two Boxes and one Button. Clicking on the Button would toggle a flag and it triggers an AnimatedVisibility animation on these Boxes.
Code
#Composable
fun TestBox() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
var flag by remember { mutableStateOf(false) }
AnimatedVisibility(
visible = !flag,
enter = slideInHorizontally(animationSpec = tween(3000)) { it },
exit = slideOutHorizontally(animationSpec = tween(3000)) { -it }
) {
// Red box
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red)
.testTag("red_box"),
) {}
}
AnimatedVisibility(
visible = flag,
enter = slideInHorizontally(animationSpec = tween(3000)) { it },
exit = slideOutHorizontally(animationSpec = tween(3000)) { -it }
) {
// Red box
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Green)
.testTag("green_box"),
) {}
}
Button(onClick = { flag = !flag }) {
Text(text = "TOGGLE")
}
}
}
Output
Now I want to write a test to check if two boxes are visible in the middle of the transition. So I wrote a test like this
class BoxAnimationTest {
#get:Rule
val composeRule = createComposeRule()
#Before
fun beforeAll() {
composeRule.setContent {
TestBox()
}
}
#Test
fun firstTest() {
with(composeRule) {
mainClock.autoAdvance = false
onNodeWithTag("red_box").assertExists() // red box visible
onNodeWithTag("green_box").assertDoesNotExist() // green box shouldn't be visible
onNodeWithText("TOGGLE").performClick() // clicking toggle button
mainClock.advanceTimeBy(1500) // and advance time to half of total duration (3000ms)
onNodeWithTag("green_box").assertExists() // now both green and
onNodeWithTag("red_box").assertExists() // [FAILED] red should be visible
mainClock.advanceTimeBy(1500) // finishing the animation
onNodeWithTag("green_box") // now green should be visible
onNodeWithTag("red_box").assertDoesNotExist() // but red shouldn't be
}
}
}
But it fails at onNodeWithTag("red_box").assertExists() (2nd).
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'red_box')
Any idea why?

Some initial investigation showed that the animations from
AnimatedVisibility were never added to the Transition, because
measure() was not called and slide animation is initialized during
measure. As a result, we ended up having an empty Transition that
finished right away. That's why the test failed. Note that adding
advanceTimeByFrame() before advanceTimeBy(1500) seems to allow the
test to pass. This might be useful for narrowing down the cause.
Source - KotlingLang Slack : https://kotlinlang.slack.com/archives/CJLTWPH7S/p1644036648038349?thread_ts=1643956469.612919&cid=CJLTWPH7S
Issue Tracker : https://issuetracker.google.com/issues/217880227

Related

What is the behavior of Jetpack Compose animations?

In my android project, I'm doing a simple Floating Action Button that can expand and show a list of buttons to perform different actions.
To track the current state of the FAB, I have the next enum class
enum class FabState {
Expanded,
Collapsed
}
For displaying the Floating Action Button, I have the following Composable function:
#Composable
fun MultiFloatingActionButton(
icon: ImageVector,
iconTint: Color = Color.White,
miniFabItems: List<MinFabItem>,
fabState: FabState, //The initial state of the FAB
onFabStateChanged: (FabState) -> Unit,
onItemClick: (MinFabItem) -> Unit
) {
val transition = updateTransition(targetState = fabState, label = "transition")
val rotate by transition.animateFloat(label = "rotate") {
when (it) {
FabState.Collapsed -> 0f
FabState.Expanded -> 315f
}
}
val fabScale by transition.animateFloat(label = "fabScale") {
when (it) {
FabState.Collapsed -> 0f
FabState.Expanded -> 1f
}
}
val alpha by transition.animateFloat(label = "alpha") {
when (it) {
FabState.Collapsed -> 0f
FabState.Expanded -> 1f
}
}
val shadow by transition.animateDp(label = "shadow", transitionSpec = { tween(50) }) { state ->
when (state) {
FabState.Expanded -> 2.dp
FabState.Collapsed -> 0.dp
}
}
Column(
horizontalAlignment = Alignment.End
) { // This is where I have my question, in the if condition
if (fabState == FabState.Expanded || transition.currentState == FabState.Expanded) {
miniFabItems.forEach { minFabItem ->
MinFab( //Composable for creating sub action buttons
fabItem = minFabItem,
alpha = alpha,
textShadow = shadow,
fabScale = fabScale,
onMinFabItemClick = {
onItemClick(minFabItem)
}
)
Spacer(modifier = Modifier.size(16.dp))
}
}
FloatingActionButton(
onClick = {
onFabStateChanged(
when (fabState) {
FabState.Expanded -> {
FabState.Collapsed
}
FabState.Collapsed -> {
FabState.Expanded
}
}
)
}
) {
Icon(
imageVector = icon,
tint = iconTint,
contentDescription = null,
modifier = Modifier.rotate(rotate)
)
}
}
}
The constants I defined are for animating the buttons that will show/hide depending on the FAB state.
When I first made the function, the original condition was giving me a different behavior, and playing around with all the posible conditions, I got 3 different results:
1st condition:
if (transition.currentState == FabState.Expanded) {...}
Result: animation not loading from collapsed to expanded, but it does from expanded to collapsed
2nd condition: if (fabState == FabState.Expanded) {...}
Result: animation loading from collapsed to expanded, but not from expanded to collapsed
3rd condition (the one I'm using right now):
if (fabState == FabState.Expanded || transition.currentState == FabState.Expanded) {...}
Result: animation loading in both ways
So my question is: how does every condition change the behavior of the animations?
Any help would be appreciated. Thanks in advance
fabState is updated as soon as onFabStateChanged is called and transition.currentState is updated when it ends the transition and transition.isRunning returns false
Animation only happens if the composable is present in the tree. When the condition is false in the if block, the elements are not available for animation.
condition 1 false during the enter perion which breaks the enter animation and condition 2 is false during the exit period which breaks the exit animation and both are false after exit. Therefore merging them solved your issue and also removes the composables from the tree when not wanted.
Better approach
AnimatedVisibility(
visible = fabState == FabState.Expanded,
enter = fadeIn()+ scaleIn(),
exit = fadeOut() + scaleOut(),
) {
miniFabItems.forEach { minFabItem ->
MinFab(
fabItem = minFabItem,
textShadow = 0.dp,
onMinFabItemClick = {
onItemClick(minFabItem)
}
)
Spacer(modifier = Modifier.size(16.dp))
}
}
And use graphicsLayer modifier to instead of rotate
Icon(
imageVector = Icons.Default.Add,
tint = Color.White,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
this.rotationZ = rotate
}
)

How to Reset the state of compose views animating

I am building an onboarding fragment that gives users tips for each screen. there are multiple pages of a few lines:
Page 1
this icon does this
that icon does that
Button: Next
Page 2
this icon does this
that icon does that
Button: Finish
I want each view on the page to fade in progressively down the screen.
Then with a new page i want the items to reset and all fade in again from the top down.
I have tried using AnimatedVisibility but the problem is that elements keep their state so the fade effect doesnt play on the second page. Possibly AnimatedVisibility isnt the right choice for what i want to do?
So this is the code for a line. I want to reset the state object on each recomposition.
Or if someone has a better suggetion on how to do it - then that would also be excellent.
#Composable
private fun Line(line: ActionResources, modifier: Modifier) {
val state = remember {
MutableTransitionState(false).apply {
// Start the animation immediately.
targetState = true
}
}
AnimatedVisibility(state,
enter = fadeIn(animationSpec = tween(durationMillis = 1000)),
exit = fadeOut(animationSpec = tween(durationMillis = 1000))
) {
Row(
modifier = modifier
.padding(16.dp)
) {
val color = line.color?.let { colorResource(it) } ?: MaterialTheme.colors.onSurface
line.icon?.also {
Icon(
painter = painterResource(it),
tint = color,
contentDescription = stringResource(id = R.string.menu_search),
modifier = Modifier.padding(end = 8.dp).size(24.dp)
)
}
line.label?.also {
Text(
modifier = Modifier,
style = MaterialTheme.typography.body1,
color = color,
text = it
)
}
}
}
}
I can't compile your code especially ActionResources, but based on this
I want to reset the state object on each recomposition.
I can only suggest supplying a key to your remember {...} using the the line parameter.
val state = remember (line) {
MutableTransitionState(false).apply {
// Start the animation immediately.
targetState = true
}
}
I'm not sure if this would solve your problem, but if you want this state to be re-calculated assuming the line parameter will always be different on succeeding re-compositions, then using it as remember {...}'s key will guarantee a re-calculation.

How to prevent Jetpack Compose ExposedDropdownMenuBox from showing menu when scrolling

I'm trying to use Jetpack Compose's ExposedDropdownMenuBox but I can't prevent it from showing the menu when scrolling.
For example, here's the code to replicate this problem:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(color = MaterialTheme.colors.background) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(20){
ExposedDropdownMenuSample()
}
}
}
}
}
}
}
ExposedDropdownMenuSample was taken from the official samples.
This is a GIF showing the problem.
How can I prevent this from happening?
This code is using compose version 1.1.0-rc01.
edit: now it doesn't swallow fling-motion as reported by m.reiter 😁
I was able to fix this with this ugly hack:
private fun Modifier.cancelOnExpandedChangeIfScrolling(cancelNext: () -> Unit) = pointerInput(Unit) {
forEachGesture {
coroutineScope {
awaitPointerEventScope {
var event: PointerEvent
var startPosition = Offset.Unspecified
var cancel = false
do {
event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.any()) {
if (startPosition == Offset.Unspecified) {
startPosition = event.changes.first().position
}
val distance =
startPosition.minus(event.changes.last().position).getDistance()
cancel = distance > 10f || cancel
}
} while (!event.changes.all { it.changedToUp() })
if (cancel) {
cancelNext.invoke()
}
}
}
}
}
then add it to the ExposedDropdownMenuBox like:
var cancelNextExpandedChange by remember { mutableStateOf(false) } //this is to prevent fling motion from being swallowed
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (!cancelNextExpandedChange) expanded = !expanded
cancelNextExpandedChange = false
}, modifier = Modifier.cancelOnExpandedChangeIfScrolling() { cancelNextExpandedChange = true }
)
So it basically checks if there was a drag for more than 10 pixels? and if true, invokes the callback that sets cancelNextExpandedChange to true so it will skip the next onExpandedChange.
10 is just a magic number that worked well for my tests, but it seems to be too low for a high res screen device. I'm sure there's a better way to calculate this number... Maybe someone more experienced can help with this until we have a proper fix?
I found a slightly less hacky workaround:
You get the info whether a scrolling is in progress from the scroll-state of the Column.
val scrollState = rememberScrollState()
val isScrolling = scrollState.isScrollInProgress
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.verticalScroll(scrollState),
...
) ...
In the ExposedDropdownMenuBox you can then change the listener to
onExpandedChange = {
expanded = !expanded && !isScrolling
},
=> The dropdown is never opened while scrolling. It is also automatically closed as soon as you start scrolling in the main-column. However scrolling inside the dropdown is possible.
Of course you can also cahnge it to something like
expanded = if (isScrolling) expanded else !expanded
To just leave everything like it is while scrolling
This issue was reported in:
https://issuetracker.google.com/issues/212091796
and fixed in androidx.compose.material:material:1.4.0-alpha02

Jetpack Compose animation finished listener not called

I'm struggling to use Jetpack Compose Animation to achieve a (supposedly simple) effect:
in the case of an error, a control's background color should flash red, and after a short delay then fade back to normal (transparent).
My current approach is to model this with a boolean state shouldFlash which is set to true when an error occurs, and is set back to false when the animation completes. Unfortunately it seems the finishedListener passed to animateColorAsState is never called. I attached a debugger and also added a log statement to verify this.
What am I doing wrong?
Sample (button triggers error):
#Composable
fun FlashingBackground() {
Column(modifier = Modifier.size(200.dp)) {
var shouldFlash by remember { mutableStateOf(false) }
var text by remember { mutableStateOf("Initial") }
FlashingText(flashing = shouldFlash, text = text) {
shouldFlash = false
text = "Animation done"
}
Button(onClick = {
shouldFlash = true
}) {
Text(text = "Flash")
}
}
}
#Composable
fun FlashingText(flashing: Boolean, text: String, flashFinished: () -> Unit) {
if (flashing) {
val flashColor by animateColorAsState(
targetValue = Color.Red,
finishedListener = { _ -> flashFinished() }
)
Text(text = text, color = Color.White, modifier = Modifier.background(flashColor))
} else {
Text(text = text)
}
}
Compose version: 1.0.5 (latest stable at time of writing), also in 1.1.0-beta02
Anyway, to understand this, you need to know how the animateColorAsState works internally.
It relies on recompositions - is the core idea.
Every time a color changes, a recomposition is triggered, which results in the updated color value being reflected on the screen. Now, what you are doing is just using conditional statements to DISPLAY DIFFERENT COMPOSABLES. Now, one Composable is actually referring to the animating value, that is, the one inside your if block (when flashing is true). On the other hand, the else block Composable is just a regular text which does not reference it. That is why you need to remove the conditional. Anyway, because after removing the conditional, what remains is only a single text, I thought it would be a waste to create a whole new Composable out of it, which is why I removed that Composable altogether and pasted the Text inside your main Composable. It helps to keep things simpler enough. Other than this, the answer by #Rafiul does work, but there is not really a need for a Composable like that, so I would still recommend using this answer instead, so that the code is easier to read.
ORIGINAL ANSWER:
Try moving the animator outside the Child Composable
#Composable
fun FlashingBackground() {
Column(modifier = Modifier.size(200.dp)) {
var shouldFlash by remember { mutableStateOf(false) }
var text by remember { mutableStateOf("Initial") }
val flashFinished: (Color) -> Unit = {
shouldFlash = false
text = "Animation done"
}
val flashColor by animateColorAsState(
targetValue = if (shouldFlash) Color.Red else Color.White,
finishedListener = flashFinished
)
//FlashingText(flashing = shouldFlash, text = text) -> You don't need this
Text(text = text, color = Color.White, modifier = Modifier.background(flashColor))
Button(onClick = {
shouldFlash = true
}) {
Text(text = "Flash")
}
}
}
Change your code like this.
FlashingBackground
#Composable
fun FlashingBackground() {
Column(modifier = Modifier.size(200.dp)) {
var shouldFlash by remember { mutableStateOf(false) }
var text by remember { mutableStateOf("Initial") }
FlashingText(flashing = shouldFlash, text = text) {
shouldFlash = false
text = "Animation done"
}
Button(onClick = {
shouldFlash = true
}) {
Text(text = "Flash")
}
}
}
FlashingText
#Composable
fun FlashingText(flashing: Boolean, text: String, flashFinished: () -> Unit) {
val flashColor by animateColorAsState(
targetValue = if(flashing) Color.Red else Color.White,
finishedListener = { _ -> flashFinished() }
)
Text(text = text, color = Color.White, modifier = Modifier.background(flashColor))
}
Edited:
The problem with your code is you are initializing animateColorAsState when you are clicking the Flash button and making shouldFlash = true. So for the first time, it just initializes the animateColorAsState, doesn't run the animation. So there will be no finishedListener call as well. Since finishedListener isn't executed, shouldFlash stays to true. So from the next call shouldFlash is already true there will be no state change. That's why from the subsequent button click, it doesn't recompose the FlashingText anymore. You can put some log in your method you won't see FlashingText after the first button click.
Keep in mind: targetValue = Color.Red will not do anything. target value should be either a state or like this condition if(flashing) Color.Red because you need to change the state to start the animation.
#Phillip's answer is also right. But I don't see any extra advantage in moving the animator outside the Child Composable if you use it like the above.

How can Android Studio launch the inline fun <T> key()?

The Code A is from the offical sample project here.
The Code B is from Android Studio source code.
I have searched the article about the function key by Google, but I can't find more details about it.
How can Android Studio launch the inline fun <T> key()? Why can't the author use Code C to launch directly?
Code A
key(detailPost.id) {
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
}
Code B
#Composable
inline fun <T> key(
#Suppress("UNUSED_PARAMETER")
vararg keys: Any?,
block: #Composable () -> T
) = block()
Code C
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
From key documentation:
key is a utility composable that is used to "group" or "key" a block of execution inside of a composition. This is sometimes needed for correctness inside of control-flow that may cause a given composable invocation to execute more than once during composition.
It also contains several examples, so check it out.
Here is a basic example of the usefulness of it. Suppose you have the following Composable. I added DisposableEffect to track its lifecycle.
#Composable
fun SomeComposable(text: String) {
DisposableEffect(text) {
println("appear $text")
onDispose {
println("onDispose $text")
}
}
Text(text)
}
And here's usage:
val items = remember { List(10) { it } }
var offset by remember {
mutableStateOf(0)
}
Button(onClick = {
println("click")
offset += 1
}) {
}
Column {
items.subList(offset, offset + 3).forEach { item ->
key(item) {
SomeComposable(item.toString())
}
}
}
I only display two list items, and move the window each time the button is clicked.
Without key, each click will remove all previous views and create new ones.
But with key(item), only the disappeared item disappears, and the items that are still on the screen are reused without recomposition.
Here are the logs:
appear 0
appear 1
appear 2
click
onDispose 0
appear 3
click
onDispose 1
appear 4
click
onDispose 2
appear 5

Categories

Resources