I try to animate a transition between current data state and previous one. I have difficulties to pu...
o
I try to animate a transition between current data state and previous one. I have difficulties to put everything in place in my mind to achieve the transition considering the previous state. I read https://developer.android.com/jetpack/compose/animation but it didn't help so far. I have a 4x4 grid compound of cells. I can move cells in all directions (imagine a 2048-like game). I'd like to do 3 transitions: 1. animate "slide" of tiles 2. animate "transform" of tiles (2 identical tiles are merged together creating a new one with higher value) 3. new tile appearance (one new tile appears at each move) I struggle on how to achieve this…
(for those who don't know 2048, here is an online example https://play2048.co/)
To start playing with animation I did this
Copy code
val anim = remember {
        TargetBasedAnimation(
            animationSpec = tween(200),
            typeConverter = Float.VectorConverter,
            initialValue = 0f,
            targetValue = 360f
        )
    }
    var playTime by remember { mutableStateOf(0L) }

    // FIXME only if both previous and current tile were non empty tiles
    LaunchedEffect(tile) {
        val startTime = withFrameNanos { it }

        do {
            playTime = withFrameNanos { it } - startTime
            val animationValue = anim.getValueFromNanos(playTime)
        } while (!anim.isFinishedFromNanos(playTime))
    }
...

    Modifier.rotate(anim.getValueFromNanos(playTime))
It "kinda" work but isn't really accurate. Despite using
tile.id
(a unique ID associated to each tile), too many tiles are animated.
and I still don't understand how to apply a tile offset transition from previous state to current
d
Could you post your code for moving the tile without animation? The animation would be easy to add if the non-animating code is already structured in a declarative way.
and I still don't understand how to apply a tile offset transition from previous state to current
Generally speaking, this can be achieved by creating a stateful animation (e.g. animate*AsState, Animatable) to store the value calculated from the previous state, so that when state changes the new value can be used as the new target for the stateful animation.
o
@Doris Liu Usually, I can manage such stateful animations and I thought I was understanding what is going on, but for this use case I'm stuck, maybe the model of compose animation I have in mind is wrong πŸ˜… With
animate*AsState
, my understanding is that a link is made between the animation spec and the animated entity, thus automatically managing the "from previous to current state" logic. Here, I'm stuck because the animation logic doesn't seem that easy to express.
Here follows my current "static" impl. The grid:
Copy code
@Composable
fun TilesGrid(
    tiles: List<Tile>,
    size: IntSize) {
    LazyVerticalGrid(GridCells.Fixed(size.width)) {
        items(tiles) { tile ->
            TileView(tile)
        }
    }
}
The tile:
Copy code
@Composable
fun TileView(tile: Tile) {
    Box {
        // only draw tile slot for empty tiles
        if (tile != Tile.EMPTY_TILE) {
            val color by animateColorAsState(tile.color)
            Box(
                Modifier
                    .padding(bottom = 8.dp, start = 2.dp, end = 2.dp)
                    .background(color, RoundedCornerShape(4.dp))
                    .fillMaxSize()
                    .padding(bottom = 4.dp),
                contentAlignment = Alignment.BottomCenter
            ) {
                Text(tile.value.toString())
            }
        }
    }
}
And my
Tile
class is
Copy code
data class Tile(val rank: Int) : Comparable<Tile> {
    val id: Long = ID++
    val value: Int = 2.0.pow(rank).toInt()

    override fun compareTo(other: Tile): Int {
        return rank - other.rank
    }

    companion object {
        private var ID: Long = 0L

        val EMPTY_TILE = Tile(0)
    }
}
With sample data, we should have various cases to handle:
Copy code
val tiles1 = listOf(
    Tile.EMPTY_TILE, Tile(3), Tile.EMPTY_TILE, Tile.EMPTY_TILE,
    Tile.EMPTY_TILE, Tile(2), Tile.EMPTY_TILE, Tile(2),
    Tile.EMPTY_TILE, Tile(2), Tile(3), Tile.EMPTY_TILE,
    Tile.EMPTY_TILE, Tile.EMPTY_TILE, Tile(2), Tile(1),
)
val tiles2 = listOf(
    Tile.EMPTY_TILE, tiles1[1], Tile.EMPTY_TILE, tiles1[7],
    Tile.EMPTY_TILE, Tile(3), tiles1[10], Tile.EMPTY_TILE,
    Tile.EMPTY_TILE, Tile.EMPTY_TILE, tiles1[14], tiles1[15],
    Tile.EMPTY_TILE, Tile(1), Tile.EMPTY_TILE, Tile.EMPTY_TILE,
)

@ExperimentalFoundationApi
@Composable
fun Playground() {
    var state by remember { mutableStateOf(true) }
    val tiles = if (state) tiles1 else tiles2

    TilesGrid2(tiles, IntSize(4, 4)) {
        state = !state
    }
}
On click on any tile, it simulates 1 movement of tiles from bottom to top merging identical tiles and inserting a new one.
I bootstrapped an animation to showcase it:
Copy code
val scale = remember(tile.id) { Animatable(0f) }
LaunchedEffect(tile.id) {
    scale.animateTo(
        targetValue = 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessMedium,
            visibilityThreshold = .3f
        )
    )
}
And applying such scale to tile
I guess that with the following code, a full reproducible example should be complete
In this configuration Stable tiles (same
tile.id
same position) do not animate (which is expected). New ones (new or result of a merge) are animated (which is expected, new
tile.id
). But tiles that are simply moving from one cell to another are animated which isn't expected. The
tile.id
being the same, I would have expected stable state. I guess this comes from the fact that such
TileView
composable is managed by
LazyVerticalGrid
?
d
Yea, this static implementation is indeed difficult to convert to a state-based animation. πŸ™‚ The reason is, for tiles to know where they should be moving towards, each tile needs to retain identity. In your impl, the tiles themselves are stateless. I would recommend making two changes to your impl above: 1) Make the tile stateful so they remember the position on the board they are placed, maybe by adding position field to the Tile class. This way when that position gets updated after a swipe it's pretty straightforward to animate. 2) Use a custom layout instead of a lazy layout, as then you can more freely control the position of the tiles via
Modifier.offset
or
place(...)
.
o
Okay I guess I see what to do now. I'll try to
map
my tiles 2D board to a list of
TileState
with the board position in a mutable state. I'm not sure about the details yet but you gave me a good lead on where to dig! Thanks
πŸ‘ 1
@Doris Liu small update on this. I wasn't able to work on it recently, but I took some time today to try your advice. I still have things to tweak but it sounds promising! I defined a stable ID for each
Tile
. I store the last update made on each tile (new, none, combination, top, bottom, left, right). When I update my board, I can determine such new state for each tile. This way, I can start playing with animation depending on the update state and distinguish the different cases. I have remaining issues related to origin of animations (when dealing with rotate + offset) but I'll keep going. I think I have stronger foundation to complete my goal now πŸ‘ Thanks πŸ™‡
d
Thanks for the update. Glad the new direction is promising. πŸ™‚
o
I still have to investigate your suggestion about getting rid of
LazyVerticalGrid
. Maybe I'll have to define a
TileState
containing my tile data, animation state and expected position in grid. Then draw list of
TileState
given such position and defined animation states. I could address the issue I currently have with animation combination.
d
In the case of a 4x4 grid, all the cells will be showing on screen, therefore not much need to have it be Lazy. Or is it due to the lack of a non-lazy grid component?
o
I was lazy myself πŸ˜… I didn't thought about the scalability issue, just by habits: for horizontal list
LazyRow
, for vertical list
LazyColumn
, for grid,
LazyVerticalGrid
πŸ™ƒ
I still have plenty of things to clean but it starts to work like I expected 😌 (TODO: anim from out of board bounds, z-order issue, cleaning code, tweaking what to animate and how (currently dummy property update), …)
(clone of an "old" game: Hues (now unavailable))
d
Nice! Thanks for sharing!πŸŽ‰