Home Android & Kotlin Books Jetpack Compose by Tutorials

12
Animating Properties Using Compose Written by Denis Buketa

Great job on completing the previous chapter. So far, in the third section of this book, you’ve learned how to use ConstraintLayout, build complex UI and react to Compose lifecycles. Those things are certainly fun, but what’s even more fun? Playing with animations! And that’s what you’ll do now. :]

In this chapter, you’ll learn how to:

  • Animate composable properties using animate().
  • Use transition() to animate multiple properties of your composables.
  • Animate composable content.
  • Implement an animated button to join a subreddit.
  • Implement an animated toast that displays when the user joins a subreddit.

Before diving straight into the animation world, you’ll create a composable representing a button that lets users join an imaginary subreddit.

You’ll start by implementing a simple button, like the one shown below:

Simple Join Button
Simple Join Button

If a user hasn’t joined the subreddit yet, they can do so by clicking the blue button with the plus icon. If the user is a member already, a white button with a blue check represents that state. Clicking the button again returns it to its previous state.

To follow along with the code examples, open this chapter’s starter project in Android Studio and select Open an existing project.

Next, navigate to 12-animating-properties-using-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go!

Note that if you skip ahead to the final project, you’ll find the completed button with all the animation logic implemented.

Now that you’re all set, it’s time to start coding.

Building JoinButton

In the components package, add a new file named JoinButton.kt, then open it and add the following code:

@Composable
fun JoinButton(onClick: (Boolean) -> Unit = {}) {

}

enum class JoinButtonState {
  IDLE,
  PRESSED
}

@Preview
@Composable
fun JoinButtonPreview() {
  JoinButton(onClick = {})
}

Not much to see here. You just created a root composable for your button and added a preview. Right now, there’s nothing to preview because you haven’t added any content yet.

You also added JoinButtonState, which represents the state of the button, The two options for the state are IDLE or PRESSED.

Next, add the following code to JoinButton():

var buttonState: JoinButtonState
    by remember { mutableStateOf(JoinButtonState.IDLE) }

// Button shape
val shape = RoundedCornerShape(corner = CornerSize(12.dp))

// Button background
val buttonBackgroundColor: Color =
    if (buttonState == JoinButtonState.PRESSED)
      Color.White
    else
      Color.Blue

// Button icon
val iconAsset: ImageVector =
    if (buttonState == JoinButtonState.PRESSED)
      Icons.Default.Check
    else
      Icons.Default.Add
val iconTintColor: Color =
    if (buttonState == JoinButtonState.PRESSED)
      Color.Blue
    else
      Color.White

Box(
    modifier = Modifier
        .clip(shape)
        .border(width = 1.dp, color = Color.Blue, shape = shape)
        .background(color = buttonBackgroundColor)
        .size(width = 40.dp, height = 24.dp)
        .clickable(onClick = {
          buttonState =
              if (buttonState == JoinButtonState.IDLE) {
                onClick.invoke(true)
                JoinButtonState.PRESSED
              } else {
                onClick.invoke(false)
                JoinButtonState.IDLE
              }
        }),
    contentAlignment = Alignment.Center
) {
  Icon(
      imageVector = iconAsset,
      tint = iconTintColor,
      modifier = Modifier.size(16.dp)
  )
}

This might look like a lot of code, but you’ll see that it’s pretty simple. Here’s a breakdown, starting from the top.

You first declared a buttonState with remember(). Ideally, you’d represent your state with PostModel, but this simplified approach is enough to demonstrate how animations work.

Next, you used RoundedCornerShape() to define the shape of the button.

You also defined the button’s background color, which will change depending on the buttonState. When the button has JoinButtonState.PRESSED, it will be white. When it’s JoinButtonState.IDLE, it will be blue.

Next, you defined the button’s icon and icon color. When the button’s state is JoinButtonState.PRESSED, you’ll represent the icon with a white plus sign. If it’s JoinButtonState.IDLE, you’ll represent it with a blue check mark.

The last thing you added is the code that emits the button’s UI. You used Box() to define the button shape and background and Icon() to define how the button’s icon will look.

For that code to work, you need to add a few imports as well:

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp

Great! Now, build the project and check the preview panel.

JoinButton — Idle State
JoinButton — Idle State

Note that you can change buttonState’s initial state to PRESSED, to preview the different settings for your button.

JoinButton — Pressed State
JoinButton — Pressed State

Awesome! Next, you’ll add this button to Post().

Adding JoinButton to Post

Before animating JoinButton(), you’ll add it to Post() so you can see it in the app.

@Composable
fun Header(
  post: PostModel,
  onJoinButtonClick: (Boolean) -> Unit = {} // here
) {
  Row(
    modifier = Modifier.padding(start = 16.dp),
    verticalAlignment = Alignment.CenterVertically // here
  ) {
    Image(
      imageResource(id = R.drawable.subreddit_placeholder),
      Modifier.size(40.dp)
        .clip(CircleShape)
    )
    Spacer(modifier = Modifier.width(8.dp))
    Column(modifier = Modifier.weight(1f)) {
      Text(
        text = stringResource(
          R.string.subreddit_header,
          post.subreddit
        ),
        fontWeight = FontWeight.Medium,
        color = MaterialTheme.colors.primaryVariant
      )
      Text(
        text = stringResource(
          R.string.post_header,
          post.username,
          post.postedTime
        ),
        color = Color.Gray
      )
    }
    Spacer(modifier = Modifier.width(4.dp)) // here
    JoinButton(onJoinButtonClick) // here
    MoreActionsMenu()
  }
  Title(text = post.title)
}
Posts With the Join Button
Nucrh Ludg lti Qeuw Lodyoy

Animating the JoinButton background

So far, you’ve made the button background change from one color to another when the state changes. In this section, you’ll animate that transition.

// Button background
val buttonBackgroundColor: Color = animate(
    if (buttonState == JoinButtonState.PRESSED)
      Color.White
    else
      Color.Blue
)
import androidx.compose.animation.animate
Join Button’s background animation
Gaan Warxat’d cocxrveetc oxivokoej

Using transitions to animate JoinButton

In the previous section, you saw how to animate one property of your composables. Now, you’ll add more content to JoinButton(). This will give you the opportunity to animate several properties at once.

Join Button with more content
Voiw Luwgiv tohj gexu zaqqufw

Defining the transition

To animate these properties, you’ll use transitions. Using transition(), you create state changes between two or more types of state and define state values for each state type. This means you can define the button width, background color, icon and icon color and text for the two state types you have — IDLE and PRESSED.

private val buttonBackgroundColor = ColorPropKey(label = "Button Background Color")
private val buttonWidth = DpPropKey(label = "Button Width")
private val iconTintColor = ColorPropKey(label = "Icon Tint Color")
private val textMaxWidth = DpPropKey(label = "Text Max Width")
private val transitionDefinition =
  transitionDefinition<JoinButtonState> {

  }
state(JoinButtonState.IDLE) {
  this[buttonBackgroundColor] = Color.Blue
  this[buttonWidth] = 70.dp
  this[iconTintColor] = Color.White
  this[textMaxWidth] = 40.dp
}

state(JoinButtonState.PRESSED) {
  this[buttonBackgroundColor] = Color.White
  this[buttonWidth] = 32.dp
  this[iconTintColor] = Color.Blue
  this[textMaxWidth] = 0.dp
}
val duration = 600
transition(
  fromState = JoinButtonState.IDLE,
  toState = JoinButtonState.PRESSED
) {
  buttonBackgroundColor using tween(duration)
  buttonWidth using tween(duration)
  iconTintColor using tween(duration)
  textMaxWidth using tween(duration)
}
transition(
  fromState = JoinButtonState.PRESSED,
  toState = JoinButtonState.IDLE
) {
  buttonBackgroundColor using tween(duration)
  buttonWidth using tween(duration)
  iconTintColor using tween(duration)
  textMaxWidth using tween(duration)
}
import androidx.compose.animation.ColorPropKey
import androidx.compose.animation.DpPropKey
import androidx.compose.animation.core.transitionDefinition
import androidx.compose.animation.core.tween
// Button transition
val transitionState = transition(
    definition = transitionDefinition,
    toState = buttonState
)
import androidx.compose.animation.transition
@Composable
fun <T> transition(
    definition: TransitionDefinition<T>, 
    toState: T, 
    clock: AnimationClockObservable = 
    	AmbientAnimationClock.current, 
    initState: T = toState, 
    label: String? = null, 
    onStateChangeFinished: (T) -> Unit = null
): TransitionState

Connecting the transition to the composables

First, remove the definitions of buttonBackgroundColor and iconTintColor from JoinButton().

Box(
  modifier = Modifier
    .clip(shape)
    .border(width = 1.dp, color = Color.Blue, shape = shape)
    .background(
      color = transitionState[buttonBackgroundColor] // here
    )
    .size(
      width = transitionState[buttonWidth], // here
      height = 24.dp
    )
    .clickable(onClick = {
      buttonState =
        if (buttonState == JoinButtonState.IDLE) {
          onClick.invoke(true)
          JoinButtonState.PRESSED
        } else {
          onClick.invoke(false)
          JoinButtonState.IDLE
        }
    }),
  contentAlignment = Alignment.Center
) {
  Row(
    verticalAlignment = Alignment.CenterVertically
  ) {
    Icon(
      imageVector = iconAsset,
      tint = transitionState[iconTintColor], // here
      modifier = Modifier.size(16.dp)
    )
    Text(
      text = "Join",
      color = Color.White,
      fontSize = 14.sp,
      maxLines = 1,
      modifier = Modifier.widthIn(
        min = 0.dp,
        max = transitionState[textMaxWidth] // here
      )
    )
  }
}
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.Text
import androidx.compose.ui.unit.sp
Posts With the Completed Join Button
Zanxx Lizj yda Zeftfiwen Waub Kanfuy

Join Button animation
Deil Kaghib uticinauh

Animating composable content

So far, you’ve seen how to animate the properties of your composables. In this section, you’ll explore a different approach to creating animations by learning how to animate composable content.

Joined Toast
Qeeguv Liomg

Adding JoinedToast

In components, create a new file named JoinedToast.kt. Then, add the following code to it:

@Composable
fun JoinedToast(visible: Boolean) {
  ToastContent()
}

@Composable
private fun ToastContent() {
  val shape = RoundedCornerShape(4.dp)
  Box(
    modifier = Modifier
      .clip(shape)
      .background(Color.White)
      .border(1.dp, Color.Black, shape)
      .height(40.dp)
      .padding(horizontal = 8.dp),
    contentAlignment = Alignment.Center
  ) {
    Row(verticalAlignment = Alignment.CenterVertically) {
      Icon(
        imageVector = vectorResource(
          id = R.drawable.ic_planet
        )
      )
      Spacer(modifier = Modifier.width(8.dp))
      Text(text = "You have joined this community!")
    }
  }
}

@Preview
@Composable
fun JoinedToastPreview() {
  JoinedToast(visible = true)
}
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import com.raywenderlich.android.jetreddit.R
JoinedToast Composable — Preview
VoahakYuefy Yofsedatxa — Lfogauy

Animating JoinedToast

In JoinedToast.kt, replace theJoinedToast() code with the following:

@ExperimentalAnimationApi
@Composable
fun JoinedToast(visible: Boolean) {
  AnimatedVisibility(
      visible = visible,
      enter = slideInVertically(initialOffsetY = { +40 }) +
          fadeIn(),
      exit = slideOutVertically() + fadeOut()
  ) {
    ToastContent()
  }
}
import androidx.compose.animation.*
@Composable
fun AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = shrinkOut() + fadeOut(),
    initiallyVisible: Boolean = visible,
    content: @Composable () -> Unit
): Unit

Bringing the JoinedToast home

Before you can see this animation in action, you need to add JoinedToast() to HomeScreen(). You also need to add @ExperimentalAnimationApi to any parent composable of JoinedToast().

@ExperimentalAnimationApi
@Preview
@Composable
fun JoinedToastPreview() {
  JoinedToast(visible = true)
}
@ExperimentalAnimationApi
@Composable
fun HomeScreen(viewModel: MainViewModel) {
  val posts: List<PostModel>
      by viewModel.allPosts.observeAsState(listOf())

  var isToastVisible by remember { mutableStateOf(false) }

  val onJoinClickAction: (Boolean) -> Unit = { joined ->
    isToastVisible = joined
    if (isToastVisible) {
      Timer().schedule(3000) {
        isToastVisible = false
      }
    }
  }

  Box(modifier = Modifier.fillMaxSize()) {
    LazyColumn(modifier = Modifier.background(color = MaterialTheme.colors.secondary)) {
      items(posts) {
        if (it.type == PostType.TEXT) {
          TextPost(it, onJoinButtonClick = onJoinClickAction)
        } else {
          ImagePost(it, onJoinButtonClick = onJoinClickAction)
        }
        Spacer(modifier = Modifier.height(6.dp))
      }
    }

    Box(
      modifier = Modifier
        .align(Alignment.BottomCenter)
        .padding(bottom = 16.dp)
    ) {
      JoinedToast(visible = isToastVisible)
    }
  }
}
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import java.util.*
import kotlin.concurrent.schedule
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Alignment
import com.raywenderlich.android.jetreddit.components.JoinedToast

Adding onJoinButtonClick to the Posts

Open Post.kt and replace TextPost(), ImagePost() and Post() with the following code:

@Composable
fun TextPost(
  post: PostModel,
  onJoinButtonClick: (Boolean) -> Unit = {}
) {
  Post(post, onJoinButtonClick) {
    TextContent(post.text)
  }
}

@Composable
fun ImagePost(
  post: PostModel,
  onJoinButtonClick: (Boolean) -> Unit = {}
) {
  Post(post, onJoinButtonClick) {
    ImageContent(post.image)
  }
}

@Composable
fun Post(
  post: PostModel,
  onJoinButtonClick: (Boolean) -> Unit = {},
  content: @Composable () -> Unit = emptyContent()
) {
  Card(shape = MaterialTheme.shapes.large) {
    Column(
      modifier = Modifier.padding(
        top = 8.dp,
        bottom = 8.dp
      )
    ) {
      Header(post, onJoinButtonClick)
      Spacer(modifier = Modifier.height(4.dp))
      content.invoke()
      Spacer(modifier = Modifier.height(8.dp))
      PostActions(post)
    }
  }
}

Adding experimental annotations

The annotation you have to add is @ExperimentalAnimationApi.

import androidx.compose.animation.ExperimentalAnimationApi
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    JetRedditApp(viewModel)
  }
}
import androidx.compose.animation.ExperimentalAnimationApi
Joined Toast
Luofal Seixx

Key points

  • You use animate() for fire-and-forget animations targeting single properties of your composables. This is very useful for animating size, color, alpha and similar simple properties.
  • You use transition() for state-based transitions using the animation configuration defined in TransitionDefinition.
  • Use transition()s when you have to animate multiple properties of your composables, or when you have multiple states between which you can animate.
  • Transitions are very good when showing content for the first time or leaving the screen, menu, option pickers and similar. They are also great when animating between multiple states when filling in forms, selecting options and pressing buttons!
  • You use AnimatedVisibility() when you want to animate the appearance and disappearance of composable content.
  • AnimatedVisibility() lets you combine different types of visibility animations and lets you define directions if you use predefined transition animations.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.