/AnimatableCompose

Add Animatable Material Components in Android Jetpack Compose. Create jetpack compose animations painless.

Primary LanguageKotlin

AnimatableCompose

Add Animatable Material Components in Android Jetpack Compose.

Create jetpack compose animation painless.

What you can create from Material 3 components right now;

  • Spacer Animation
  • Text Animation
  • Box Animation
  • Card Animation
  • Icon Animation
  • LazyRow Animation
  • and combinations

How it looks

Phone Number Card Dealer

Phone Number

States
//Create components state
val animatableCardState = rememberAnimatableCardState(
    initialSize = DpSize(80.dp, 80.dp),
    targetSize = DpSize(Dp.Infinity, 120.dp),
    toTargetSizeAnimationSpec = tween(500, 500), //  specify delay(500) for target
    initialShape = RoundedCornerShape(32.dp),
    targetShape = RoundedCornerShape(0.dp),
    toTargetShapeAnimationSpec = tween(500, 500),
    initialOffset = DpOffset(0.dp, 0.dp),
    targetOffset = DpOffset(0.dp, - Dp.Infinity),
    toInitialOffsetAnimationSpec = tween(500, 500),
)
val animatableIconState = rememberAnimatableIconState(
    initialSize = DpSize(40.dp, 40.dp),
    targetSize = DpSize(80.dp, 80.dp),
    toTargetSizeAnimationSpec = tween(500,500),
    initialOffset = DpOffset(0.dp, 0.dp),
    targetOffset = DpOffset((-50).dp, 0.dp),
    toTargetOffsetAnimationSpec = tween(500, 500)
)
val animatableTextState = rememberAnimatableTextState(
    initialFontSize = 0.sp,
    targetFontSize = 26.sp,
    toTargetFontSizeAnimationSpec = tween(500, 500),
    initialOffset = DpOffset(0.dp, 0.dp),
    targetOffset = DpOffset((-25).dp, 0.dp),
    toTargetOffsetAnimationSpec = tween(500, 500)
)
        
// Create shared state
val sharedAnimatableState = rememberSharedAnimatableState(
    listOf(
        animatableCardState,
        animatableIconState, // default index = 0
        animatableIconState.copy( // create state with copy func. for same params
            index = 1, // specify index for same components
            initialSize = DpSize(0.dp, 0.dp),
            targetSize = DpSize(36.dp, 36.dp),
            targetOffset = DpOffset(40.dp, 0.dp),
        ),
        animatableTextState, // default index = 0
        animatableTextState.copy(
            index = 1, // specify index for same components
            targetFontSize = 12.sp
        )
    )
)
Components
AnimatableCard(
    onClick = {
        sharedAnimatableState.animate()
    },
    state = sharedAnimatableState // pass shared state
) {
    Row(
        modifier = Modifier.fillMaxSize(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center
    ) {
        AnimatableIcon(
            imageVector = Icons.Default.Person,
            contentDescription = null,
            state = sharedAnimatableState // pass shared state
        )
        Column {
            AnimatableText(
                text = "Emir Demirli",
                state = sharedAnimatableState // pass shared state
            )
            AnimatableText(
                text = "+90 0535 508 55 52",
                state = sharedAnimatableState, // pass shared state
                stateIndex = 1 // specify index for same components
            )
        }
        AnimatableIcon(
            imageVector = Icons.Default.Phone,
            contentDescription = null,
            state = sharedAnimatableState, // pass shared state
            stateIndex = 1 // specify index for same components
        )
    }
}

Card Dealer

States
val cards by remember  { 
    mutableStateOf(listOf("A","K","Q","J","10","9","8","7","6","5","4","3","2"))
}
var deck by remember {
    mutableStateOf(cards + cards + cards + cards)
}

val animatableCardState = rememberAnimatableCardState(
    initialSize = DpSize(64.dp, 64.dp),
    targetSize = DpSize(64.dp, 64.dp),
    initialOffset = DpOffset(0.dp, 120.dp),
    targetOffset = DpOffset(-Dp.Infinity, -Dp.Infinity)
)
val animatableTextState = rememberAnimatableTextState(
    initialFontSize = 0.sp,
    targetFontSize = 24.sp
)

val cardStates = mutableListOf<AnimatableState>()
val textStates = mutableListOf<AnimatableState>()

deck.indices.forEach {
    cardStates.add(
        animatableCardState.copy(
            index = it,
            toTargetOffsetAnimationSpec = tween(400, (it * 400)),
            targetOffset = DpOffset(if(it % 2 == 0) (-100).dp else 100.dp, (-150).dp)
        )
    )
    textStates.add(
        animatableTextState.copy(
            index = it,
            toTargetFontSizeAnimationSpec = tween(400, (it * 400))
        )
    )

}

val sharedAnimatableState = rememberSharedAnimatableState(cardStates + textStates)
Components
Box(
    modifier = Modifier
        .fillMaxSize()
        .clickable {
            deck = deck.shuffled()
            sharedAnimatableState.animate()
        },
    contentAlignment = Alignment.Center
) {
    deck.indices.forEach {
        AnimatableCard(
            onClick = {},
            state = sharedAnimatableState,
            stateIndex = it,
            fixedShape = RoundedCornerShape(16.dp)
        ) {
            Box(Modifier.fillMaxSize(), Alignment.Center) {
                AnimatableText(
                    text = deck[it],
                    state = sharedAnimatableState,
                    stateIndex = it
                )
            }
        }
    }
}
Insta Story Info Card

Insta Story

States
val lazyListState = rememberLazyListState()
val scope = rememberCoroutineScope()
var selectedIndex by remember { mutableStateOf(0) }

val stories by remember { mutableStateOf(Story.stories) }

val animatableCardState = rememberAnimatableCardState(
    initialSize = DpSize(width = 70.dp, height = 70.dp),
    targetSize = DpSize(width = Dp.Infinity, height = Dp.Infinity),
    initialShape = CircleShape,
    targetShape = RoundedCornerShape(0.dp),
    initialPadding = PaddingValues(4.dp, 8.dp),
    targetPadding = PaddingValues(0.dp),
    initialBorder = BorderStroke(2.dp, Brush.verticalGradient(listOf(Color.Red, Color.Yellow))),
    targetBorder = BorderStroke(0.dp, Color.Unspecified)
)

val cardStates = mutableListOf<AnimatableState>()

stories.indices.forEach { index ->
    cardStates.add(
        animatableCardState.copy(
            index = index,
            onAnimation = {
                when(it) {
                    AnimationState.INITIAL -> {}
                    AnimationState.INITIAL_TO_TARGET -> {
                        scope.launch {
                            delay(150)
                            lazyListState.animateScrollToItem(selectedIndex)
                        }
                    }
                    AnimationState.TARGET -> {}
                    AnimationState.TARGET_TO_INITIAL -> {}
                }
            },
            toTargetAnimationSpec = tween(250)
        )
    )
}

val sharedAnimatableState = rememberSharedAnimatableState(cardStates)
Components
Box(
    modifier = Modifier.fillMaxSize(),
) {
    LazyRow(
        state = lazyListState
    ) {
        items(stories.size) { index ->
            AnimatableCard(
                modifier = Modifier
                    .size(100.dp),
                onClick = {
                    selectedIndex = index
                    cardStates[index].animate()
                },
                state = sharedAnimatableState,
                stateIndex = index
            ) {
                AsyncImage(
                    model = stories[index].url,
                    contentDescription = null,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier.fillMaxSize()
                )
            }
        }
    }
}
Data
data class Story(
    val url: String
) {
    companion object {
        val stories = listOf(
            //
        )
    }
}

Info Card

States
val lazyListState = rememberLazyListState()
val snapperFlingBehavior = rememberSnapperFlingBehavior(
    lazyListState = lazyListState,
    snapOffsetForItem = SnapOffsets.Start,
)
val scope = rememberCoroutineScope()
var selectedIndex by remember { mutableStateOf(0) }

val animatableCardState = rememberAnimatableCardState(
    initialSize = DpSize(width = 340.dp, height = 180.dp),
    targetSize = DpSize(width = Dp.Infinity, height = 340.dp),
    initialShape = RoundedCornerShape(32.dp),
    targetShape = RoundedCornerShape(0.dp, 0.dp, 32.dp, 32.dp),
    toTargetShapeAnimationSpec = tween(750),
    initialPadding = PaddingValues(horizontal = 8.dp),
    targetPadding = PaddingValues(0.dp),
    onAnimation = {
        when(it) {
            AnimationState.INITIAL -> {}
            AnimationState.INITIAL_TO_TARGET -> {
                scope.launch {
                    delay(500)
                    lazyListState.animateScrollToItem(selectedIndex)
                }
            }
            AnimationState.TARGET -> {}
            AnimationState.TARGET_TO_INITIAL -> {}
        }
    }
)
val animatableBoxState = rememberAnimatableBoxState(
    initialAlignment = Alignment.Center,
    targetAlignment = Alignment.TopCenter
)
val animatableTextState = rememberAnimatableTextState(
    initialFontSize = 0.sp,
    targetFontSize = 12.sp,
    initialOffset = DpOffset(x = 0.dp, y = 300.dp),
    targetOffset = DpOffset(x = 0.dp, y = 0.dp),
    toTargetAnimationSpec = tween(250)
)
val animatableSpacerState = rememberAnimatableSpacerState(
    initialSize = DpSize(width = 0.dp, height = 0.dp),
    targetSize = DpSize(width = 0.dp, height = 16.dp)
)

val infoCards by remember { mutableStateOf(InfoCard.infoCards) }

val cardStates = mutableListOf<AnimatableState>()
val boxStates = mutableListOf<AnimatableState>()
val textStates = mutableListOf<AnimatableState>()
val spacerStates = mutableListOf<AnimatableState>()

infoCards.indices.forEach { index ->
    cardStates.add(
        animatableCardState.copy(
            index = index
        )
    )
    boxStates.add(
        animatableBoxState.copy(
            index = index
        )
    )
    textStates.add(
        animatableTextState.copy(
            index = index
        )
    )
    if(index == 0) {
        spacerStates.add(
            animatableSpacerState.copy(
                index = index,
                initialSize = DpSize(width = 0.dp, height = 300.dp),
                targetSize = DpSize(width = 0.dp, height = 0.dp)
            )
        )
    }
    spacerStates.add(
        animatableSpacerState.copy(
            index = index + 1,
        )
    )
}

val sharedAnimatableState = rememberSharedAnimatableState(
    animatableStates = cardStates + boxStates + textStates + spacerStates
)
Components
Column(
    modifier = Modifier.fillMaxSize(),
) {
    AnimatableSpacer(
        state = sharedAnimatableState
    )
    LazyRow(
        verticalAlignment = Alignment.CenterVertically,
        state = lazyListState,
        flingBehavior = snapperFlingBehavior
    ) {
        items(infoCards.size) { index ->
            AnimatableCard(
                onClick = {
                    selectedIndex = index
                    sharedAnimatableState.animate()
                },
                state = sharedAnimatableState,
                stateIndex = index,
                colors = CardDefaults.cardColors(
                    containerColor = Color(0xFFE9E7FE)
                )
            ) {
                Row(
                    modifier = Modifier.fillMaxSize(),
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    AnimatableBox(
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                            .padding(16.dp),
                        stateIndex = index,
                        state = sharedAnimatableState
                    ) {
                        LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
                            item {
                                Text(
                                    text = infoCards[index].title,
                                    fontSize = 22.sp,
                                    fontWeight = FontWeight.Bold
                                )
                                Text(
                                    modifier = Modifier.align(Alignment.CenterStart),
                                    text = "MGS 1",
                                    fontSize = 12.sp,
                                    color = Color.Gray
                                )
                                AnimatableSpacer(
                                    stateIndex = index + 1,
                                    state = sharedAnimatableState
                                )
                                AnimatableText(
                                    text = infoCards[index].info,
                                    stateIndex = index,
                                    state = sharedAnimatableState,
                                    fontWeight = FontWeight.Bold
                                )
                            }
                        }
                    }
                    AsyncImage(
                        modifier = Modifier
                            .weight(1f)
                            .padding(8.dp)
                            .clip(RoundedCornerShape(32.dp)),
                        model = infoCards[index].imageUrl,
                        contentDescription = null,
                        contentScale = ContentScale.Crop
                    )
                }
            }
        }
    }
}
Data
data class InfoCard(
    val imageUrl: String,
    val title: String,
    val info: String
){
    companion object {
        val infoCards = listOf(
            //
        )
    }
}

How to use

You can learn to use it step by step, you need to use state and components together.

AnimatableText

State
// Simply create state and pass it to AnimatableText
val state = rememberAnimatableTextState(
    initialFontSize = 12.sp,
    targetFontSize = 60.sp
)
Component
Column(
    modifier = Modifier
        .fillMaxSize()
        .clickable {
            state.animate() // animate
        },
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    AnimatableText(
        text = "Animatable",
        state = state // pass state
    )
    AnimatableText(
        text = "Compose",
        state = state // pass state
    )
}

AnimatableBox

State
// Simply create box state and pass it to AnimatableBox
val state = rememberAnimatableBoxState(
    initialSize = DpSize(60.dp, 60.dp), // set initial size
    targetSize = DpSize(Dp.Infinity, 120.dp), // set target size
    initialOffset = DpOffset(x = 0.dp, y = 0.dp), // set initial offset
    targetOffset = DpOffset(x = 0.dp, y = - Dp.Infinity) // set target offset
    // Dp.Infinity will take the maximum value according to the screen size, 
    // ps: Dp.Infinity for offset needs centered component and sizes, however you may not use it if you want
    initialAlignment = Alignment.Center,  // set initial alignment
    targetAlignment = Alignment.TopStart // set target alignment
)
Component
AnimatableBox(
    modifier = Modifier
        .border(1.dp, Color.Red)
        .clickable {
            state.animate()
        },
    state = state
) {
    Icon(
        modifier = Modifier.padding(8.dp),
        imageVector = Icons.Default.Add,
        contentDescription = null
    )
}

AnimatableCard

State
// Simply create card state and pass it to AnimatableCard
val animatableCardState = rememberAnimatableCardState(
    initialSize = DpSize(width = 70.dp, height = 70.dp),
    targetSize = DpSize(width = 200.dp, height = 70.dp),
    initialShape = CircleShape,
    targetShape = RoundedCornerShape(0.dp, 0.dp, 24.dp, 0.dp),
    initialOffset = DpOffset(x = 0.dp, y = 0.dp),
    targetOffset = DpOffset(x = - Dp.Infinity, y = - Dp.Infinity)
)
Component
Box(
    modifier = Modifier
        .fillMaxSize()
        .clickable {
            animatableCardState.animateToInitial() // animate to initial
        },
    contentAlignment = Alignment.Center
) {
    AnimatableCard(
        modifier = Modifier.size(100.dp),
        onClick = {
            animatableCardState.animateToTarget() // animate to target
        },
        state = animatableCardState
    ) {}
}

AnimatableCardWithText

States
// Simply create card state and text state
val animatableCardState = rememberAnimatableCardState(
    initialSize = DpSize(width = 50.dp, height = 25.dp),
    targetSize = DpSize(width = 300.dp, height = 150.dp),
    initialShape = CircleShape,
    targetShape = RoundedCornerShape(16.dp)
)
val animatableTextState = rememberAnimatableTextState(
    initialFontSize = 4.sp,
    targetFontSize = 36.sp
)
// Merge the states you created into sharedState and pass it to AnimatableCard and AnimatableText
val sharedAnimatableState = rememberSharedAnimatableState(
    animatableStates = listOf(
        animatableCardState,
        animatableTextState
    ),
    toTargetAnimationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse) //specify shared animation spec
)
Components
Box(
    modifier = Modifier
        .fillMaxSize()
        .clickable { sharedAnimatableState.animate() },
    contentAlignment = Alignment.Center
) {
    AnimatableCard(
        modifier = Modifier.size(100.dp),
        state = sharedAnimatableState // pass shared state
    ) {
        Box(Modifier.fillMaxSize(), Alignment.Center) {
            AnimatableText(
                text = "Animatable",
                state = sharedAnimatableState // pass shared state
            )
        }
    }
}

Setup

  1. Open the file settings.gradle (it looks like that)
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        // add jitpack here 👇🏽
        maven { url 'https://jitpack.io' }
       ...
    }
} 
...
  1. Sync the project
  2. Add dependencies
dependencies {
    implementation 'com.github.commandiron:AnimatableCompose:1.0.5'
}

Todo ✔️

  • SharedAnimationSpec ✔️