I struggle to achieve "complex" transition of my e...
# compose
o
I struggle to achieve "complex" transition of my entities. I have a list of items, each of them having an "update cause". Depending on it, I have different transition to apply to the item's composable. So, I need to animate several values of different nature at the same time, the animation "spec" depending on "update cause". Both beginning and ending state depends on such state. I have a quick & dirty "working" impl (not smooth anim, not very nice code…) which I try to clean using
updateTransition
(doc) without a lot of success so far…
Here is my quick & dirty impl
Copy code
val duration = 600
    var size by remember { mutableStateOf(IntSize.Zero) }
    val scale = remember(
        tile.id,
        tile.update
    ) { Animatable(if (animateUpdate && tile.update == TileUpdate.NEW) 0f else 1f) }
    val angle = remember(tile.id, tile.update) { Animatable(0f) }
    val offsetX = remember(tile.id, tile.update) {
        Animatable(
            when (tile.update) {
                TileUpdate.MOVED_LEFT -> size.width.toFloat()
                TileUpdate.MOVED_RIGHT -> -size.width.toFloat()
                else -> 0f
            }
        )
    }
    val offsetY =
        remember(tile.id, tile.update) {
            Animatable(
                when (tile.update) {
                    TileUpdate.MOVED_UP -> size.height.toFloat()
                    TileUpdate.MOVED_DOWN -> -size.height.toFloat()
                    else -> 0f
                }
            )
        }
    LaunchedEffect(tile) {
        when (tile.update) {
            TileUpdate.NONE -> {
                scale.snapTo(1f)
                angle.snapTo(0f)
                offsetX.snapTo(size.width.toFloat())
                offsetY.snapTo(size.height.toFloat())
            }
            TileUpdate.NEW -> {
                scale.animateTo(
                    targetValue = 1f,
                    animationSpec = tween(duration)
                )
                angle.snapTo(0f)
                offsetX.snapTo(0f)
                offsetY.snapTo(0f)
            }
            TileUpdate.COMBINED -> {
                scale.snapTo(1f)
                angle.animateTo(
                    targetValue = 180f, // FIXME 180f mirrors the card, what we want is tile flip effect without mirroring
                    animationSpec = tween(duration)
                )
                offsetX.snapTo(0f)
                offsetY.snapTo(0f)
            }
            TileUpdate.MOVED_UP,
            TileUpdate.MOVED_DOWN -> {
                scale.snapTo(1f)
                angle.snapTo(0f)
                offsetX.snapTo(0f)
                offsetY.animateTo(
                    targetValue = 0f,
                    animationSpec = tween(duration)
                )
            }
            TileUpdate.MOVED_LEFT,
            TileUpdate.MOVED_RIGHT -> {
                scale.snapTo(1f)
                angle.snapTo(0f)
                offsetX.animateTo(
                    targetValue = 0f,
                    animationSpec = tween(duration)
                )
                offsetY.snapTo(0f)
            }
        }
    }

Box(
    Modifier
        .onSizeChanged { if (size == IntSize.Zero) size = it }
        .scale(scale.value)
        .graphicsLayer(rotationY = angle.value)
        .offset(x = offsetX.value.dp, y = offsetY.value.dp)
)
The example provided in the doc seems quite similar to what I need but I don't get how deal with "initial value" and "target value" depending on my state 🤔
Also, for such animations, the
AnimationSpec
might need customization
r
I'm trying to learn animations myself right now. If you can provide sample code that I can paste and run, I'll play with it. But that code does not. Seems like it needs the tile and TileUpdate.
o
@robnik here you go, should be easier with this 🙂 https://github.com/opatry/compose-tiles-anim
(It's a Compose for Desktop project but nothing but the
main
is tight to desktop impl, should be easy to move
TilesApp
in an Android existing Compose Android project)
I tried to bootstrap with this
Copy code
var size by remember { mutableStateOf(IntSize.Zero) }
val transitionData = updateTransitionData(tile.update, size)
Box(
    modifier
        .onSizeChanged { if (size == IntSize.Zero) size = it }
        .scale(transitionData.scaleX, transitionData.scaleY)
        .offset(transitionData.offsetX, transitionData.offsetY)
)
Copy code
class TileTransitionData(
    scaleX: State<Float>,
    scaleY: State<Float>,
    offsetX: State<Dp>,
    offsetY: State<Dp>,
) {
    val scaleX by scaleX
    val scaleY by scaleY
    val offsetX by offsetX
    val offsetY by offsetY
}

@Composable
private fun updateTransitionData(updateState: TileUpdate, size: IntSize): TileTransitionData {
    val transition = updateTransition(updateState)
    val scaleX = transition.animateFloat { update ->
        when (update) {
            TileUpdate.NEW -> 0f // animate from 0 to 1f
            TileUpdate.COMBINED -> 0f // animate from 1f to 0f to 1f
            else -> 1f // no animation
        }
    }
    val scaleY = transition.animateFloat { update ->
        when (update) {
            TileUpdate.NEW -> 0f // animate from 0 to 1f
            else -> 1f // no animation
        }
    }
    val offsetX = transition.animateDp { update ->
        when (update) {
            TileUpdate.MOVED_LEFT -> size.width.dp // animate from width to 0f
            TileUpdate.MOVED_RIGHT -> -size.width.dp // animate from -width to 0f
            else -> 0.dp // no animation
        }
    }
    val offsetY = transition.animateDp { update ->
        when (update) {
            TileUpdate.MOVED_UP -> size.height.dp // animate from height to 0f
            TileUpdate.MOVED_DOWN -> -size.height.dp // animate from -height to 0f
            else -> 0.dp // no animation
        }
    }
    return remember(transition) { TileTransitionData(scaleX, scaleY, offsetX, offsetY) }
}
But I can't make anything work because I don't know how to 1. define that I want an initial state and a target state 2. a trigger from initial to target state
Internally
MutableTransitionState
seems to be something close to what I'd like to use but I can't reach it from
updateTransition
AFAIK
I have the feeling the
Transition.kt
impl internally have the tools I need but I can't use them and the open APIs doesn't suit my needs…
r
The code you posted - is this one step in a larger game where tiles are moving around the board in multiple steps?
o
yes
In the sample I extracted, I have 4 rows with "Empty (no anim)", "move from left (move from -width to 0)", "scale (from 0 to 1), expand (width from 1 to 0 to 1)"
clicking the button regenerates new set of tile that retriggers the animation
d
updateTransition()
takes a
MutableTransitionState
as a parameter.
Checkout this conversation I had with Doris
d
But I can't make anything work because I don't know how to
define that I want an initial state and a target state
a trigger from initial to target state
MutableTransitionState
is likely what you need, as Eric mentioned. There's an
updateTransition
API variant that takes a
MutableTransitionState
instead of
T
: https://developer.android.com/reference/kotlin/androidx/compose/animation/core/package-summary#updatetransition_1 It allows you to define an initial state and a target state for transition as you can see in the sample code. You could also change the target state of the transition by manipulating the
MutableTransitionState
.
o
I tried that but I think I'm not following the right path… I'm kind of lost with what I need to do 😄
In both the doc and the discussion you both had, it seems there is a binary state and that's it. In my mind, I have plenty of things, not a "simple" binary state 😅
So, I'm confused and I think I try to map wrong things to transition
In my use cas, would you recommend simply store a boolean (just meant to change a state and trigger the transition?), and from this transition, call as many
transition.animateXXX()
as needed?
d
The API itself isn't limited to a binary state, you could do have a
MutableTransitionState<TileUpdate>
. I think in your case, the TileUpdate states that you defined would work better than a binary state.
o
But, this won't recompose when my tile updates 🤔 (unless I "toggle" it with a
LaunchedEffect(tile.update)
? Which seems hacky
But, I don't understand how I can deal with initial and target using my state…
d
But, this won't recompose when my tile updates
Do you update the
MutableTransitionState#targetState
when the tile updates?
o
I could yes… but I don't link the dots 🤔
(My Android Studio is currently totally broken 😅 I try to restart it, so hard to write code from IDE 😛)
Copy code
val tileState = remember { MutableTransitionState(tile.update) }
    tileState.targetState = tile.update

    val transition = updateTransition(tileState)
    val scale by transition.animateFloat {
       ???
       // here depending on tile.update (it) value, I'd like to animate from X to Y
    }
d
MutableTransitionState
is a way to hoist the states of the transition. It allows you to control the transition (both initial and target state) via
MutableTransitionState#targetState
, and observe the state change (i.e. the purpose of hoisting). The code above looks right.
Transition.animateFloat
would be same as what you had before:
Copy code
val scale by transition.animateFloat {
   when (it) {
      TileUpdate.NEW -> ...
      TileUpdate.... -> ...
      else -> ...
   }
}
o
Yes, but how can I tell I want to scale from 0 to 1 here for instance, this is the point where I block I think
Here, I can only define a single value 🤔
(can't restart Android Studio anymore… seems totally broken, I'll try with Canary 🙃)
d
I see what the question is finally. 😅 The from and to values are derived from the initial/target state. When is the scale 0, is it in the
TileUpdate.NEW
state?
o
When in
TileUpdate.NEW
, I'd like the tile UI to animate both scaleX and scaleY from 0 to 1 When in
TileUpdate.MOVED_DOWN
, I'd like the tile UI to animate offsetY from -tileHeight to 0 When in
TileUpdate.COMBINED
, I'd like the tile UI to animate scaleX (only) from 1 to 0 then back to 1 (using keyframes I guess)
Each state has its own standalone animation
At the end of each animation, the tile UI state should be the same for all states
d
These states are more like actions than declaring what the UI should look like at that time, because if you get
TileUpdate.MOVE_DOWN
twice in a row, you'd want the UI to reflect two movements, rather than considering the 2nd request no-op.
o
I guess you are right but… then I think I'm even more lost than at the beginning 😅
I don't get where I miss the point, but I understand I miss it the hard way!
d
For the code you already have, I think it's easier to continue with
Animatable
, as they work better for imperative usages where you define how the UI should change. For a more declarative way to define this, just for the sake of comparison:
Copy code
class Tile {
   var combined: Boolean
   var x: Int
   var y: Int
}
After each move (left/right/up/down), you'd calculate the new tile position. Each tile, after receive their new position, can make a decision on how/whether to animate to the new position.
o
Assuming x and y are grid coordinates (from 0 to 3 for a 4x4 grid), on the composable side, how would I know if I should animate up, down, left or right? Or do you mean this should be Dp coordinates?
d
Yes, you can simplify the x, y as id from 0 to 3 for 4x4 grid. On the composable side, you know the previous position is say (0, 2) the new position is (0, 3), so it's moving down.
o
How could I know the previous position on composable side given that I only have a state of current recomposition?
d
You could add a function to Tile:
Copy code
class Tile {
   var combined: Boolean by mutableStateOf
   var x: Int by mutableStateOf..
   var y: Int by mutableStateOf..
   @Composable
   fun updatePosition(newX: Int, newY: Int)
}
If you only allow the position to be mutated through the composable function, that's a place where you will have both the previous state and current state. 🙂
o
But this comes from a business logic not related to compose not even any UI 🤔 My logic exposes a state consumed by a mutable state on compose side. When user interacts, it forwards commands to logic which notifies update snapshot…
The composable can't take any decision on its own, there are rules to follow in the business logic
d
The logic for interpreting what user interaction means can live anywhere - it doesn't need to be in the UI.
o
In your suggestion, such
Tile
class is a UI class, right? In my mind,
Tile
is part of the business model and mutated by the business logic. After each user interaction, an updated is notified to whoever is interested. My composable being one of them.
d
Tile
isn't a UI class, it's just data.
o
But you suggest to add
@Composable updatePosition()
d
Tile
holds the data of its index on the board. UI interprets where to place it based on the index.
o
Ok, here we agree 👍
Until there, I'm 100% fine
But to properly animate, I need to know what is the cause of the update. So I need to know what was my previous state or where do I come from. And this I don't understand at all 😕
d
But you suggest to add 
@Composable updatePosition()
That's a demonstration of how you can get a hold of two states simultaneously. You could instead do:
Copy code
@Composable
fun updatePosition(tile: Tile, newX: Int, newY: Int)
in the UI code.
It's analogous to having a setter - you'll momentarily have both the old and new values as you enter the setter, before you mutate the field. 🙂
o
I really have the feeling being stupid, and believe me I really try to get it but I don't at all 😭
How can I determine
newX
and
newY
from UI side given that it's a business logic responsibility/knowledge?
d
No worries, the declarative paradigm is mindset shift.
o
I'll try tomorrow with a fresh brain because for now, I can't make it through my brain…
anyway, thanks for helping, much appreciated 👍
👍 1
d
How can I determine 
newX
 and 
newY
 from UI side given that it's a business logic responsibility/knowledge?
If the same tile object or if it has some other identity, you can leverage composable function to do a bit of local caching for you.
What I meant by that is, if you simply updating `Tile`'s fields in business logic, and you have a
TileView
composable function like you linked above, to determine the newX, newY, and oldX, and oldY, you could do something like:
Copy code
@Composable
fun TileView(tile: Tile) {
    var x by remember { mutableStateOf(tile.x) }
    var y by remember { mutableStateOf(tile.y) }
    // Here x, y contain the old x, y position, since they haven't been updated yet.
    // Do something with the old value and new value as needed....
    ....

    // update x, y as needed 
    x = tile.x
    y = tile.y
}
o
Oh I see (finally!) Might help getting rid of my direction states indeed. 👍 I'll try this new strategy tomorrow
👍 2
d
Doris is amazing!
💯 2
o
Ok, I think I leave this topic… I can't make sense of anything we discussed… Everything is now even more blurry in my mind, I might try to achieve something smarter than me. Right now I randomly put
var
,
remember
,
mutableStateOf
, deep copy data model without understanding anything anymore. It's time to have a break it seems 🙂 I thought I was understanding something to Compose but it seems I'm not after all 😅 I'll stick to built-in
animateColorAsState
and static tile layout. I can't determine were the issue(s) should be fixed.
@dewildte & @Doris Liu don't get me wrong, you both were amazing taking time trying to help me. Thank you very much for this. I still hope I'll make sense of your hints and suggestions at some point 🤞. I didn't wanted to be negative about your input!
d
No worries, @Olivier Patry . Sometimes you gain clarity by stepping away from a problem and come back later with a fresh perspective. 😄
💪 1
💯 1
t
Thank you Doris for your great explanations, and for your line of questioning Olivier, this is helping me to get unstuck on a similar problem regarding list animations. 🙏🏼
d
Glad to hear this conversation was helpful. Curious what's the problem you were stuck on? @Tash
t
For sure. The problem was about trying to animate insertions/removals of draggable items in a list (visually shown as a "stack"). The underlying list of items can change via business logic like so: 1. user requests to remove 2. business logic checks whether to allow/disallow removal 3. business logic notifies UI of its decision 4. UI must apply the decision such as "remove" / "rollback" / "add" as an animation on the draggable item So far the implementation has: • the stack of overlapping items is represented by a
Box
• each
Item
Composable in the
Box
is draggable & has its own
ItemDragState
to hold offset data, isDragging, etc. • A composite
ItemsBoxState
for the whole thing. Declares
mutableStateListOf<Item>
to represent the items list • Also contains
mutableStateMapOf<Item, ItemDragState>
to be able to access the
ItemDragState
for a given
Item
in order to imperatively animate it based on the business logic decision The main challenge right now is syncing the item change animation when the backing data
mutableStateListOf<Item>
needs updating. Attempting to use the example from
AnimatedVisiblilityLazyColumnDemo
+
DisposableEffect
to know when to sync removed/added items.
Basically struggling with the paradox: "I want to remove an item from the data list, but Compose needs it to still be there in order to show the removal animation" Wondering if something like Flutter's
AnimatedList
is possible in Compose 🤔
d
I see. I'm working on an API to give more controls to
AnimatedVisibility
that will make this use case a lot easier. More specifically, it will support
MutableTransitionState<Boolean>
as the
visible
param. That will make the add/remove a lot easier. The
MutableTransitionState
has two fields,
targetState
and
currentState
. The former can be mutated by users (of the API), the latter is only mutable by the animation but is observable. So then the flow becomes: 1. user requests to remove, 2. business logic checks... and 3. sets the
targetState
as needed,
AnimatedVisibility
responds to that change, and eventually update
currentState
when it finishes animating out
😍 1
🙏🏼 1
and of course, last step is to remove the item once both
currentState
and
targetState
are the same. This will make it easier to sync the data between UI and business logic. 🙂
t
This is great.
visible: MutableTransitionState<Boolean>
will indeed be perfect for this use case. Right now there's a lot of juggling of
visible
&
initiallyVisible
params of
AnimatedVisibility(...)
to achieve the effect provided by a mutable
targetState
. Thank you, Doris! Is this change planned for any of the beta releases?
🙂 1
d
I'm aiming to get it out in the beta release after the next one. 🙂
🎉 1
🙏🏼 1