Chapters

Hide chapters

Jetpack Compose by Tutorials

First Edition · Android 11 · Kotlin 1.4 · Android Studio Canary - Arctic Fox Release

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*AsState().
  • Use updateTransition() 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,
    contentDescription = "Plus Icon",
    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.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.runtime.*
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.tooling.preview.Preview
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.

Open Post.kt and edit Header() to look like this:

@Composable
fun Header(
  post: PostModel,
  onJoinButtonClick: (Boolean) -> Unit = {} // here
) {
  Row(
    modifier = Modifier.padding(start = 16.dp),
    verticalAlignment = Alignment.CenterVertically // here
  ) {
    Image(
      ImageBitmap.imageResource(id = R.drawable.subreddit_placeholder),
      contentDescription = stringResource(id = R.string.subreddits),
      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)
}

In the code above, you added:

  1. verticalAlignment to Row() to center the header content vertically.
  2. JoinButton() and Spacer() to Header().
  3. onJoinButtonClick to Header().

Excellent! Now, build and run the app. Check how your posts look:

Posts With the Join Button
Posts With the Join Button

Click one of the JoinButtons and you’ll see how the icon and the background change instantly.

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.

In JoinButton.kt, replace the current definition of buttonBackgroundColor with the following code:

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

Add one more import as well:

import androidx.compose.animation.animateColorAsState

Here, you wrapped the if clause that defined two different background colors with animateColorAsState(). By doing that, you implemented the animation between the two colors when the state changes.

With this simple change, you added your first animation. Can you even believe how easy that was? :]

Now, take a closer look at animateColorAsState(). It is just one of the animate*AsState() functions. In the Jetpack Compose documentation, you’ll find a dozen different animate*AsState() signatures that allow you to animate a dozen different properties out of the box, including Float, Color, Dp, Position, Size and other. You can even define your own properties.

All those definitions have something in common: You use them for fire-and-forget animations. Once you create a fire-and-forget animation, the app will memorize its position, like other composables. To trigger the animation, or alter the course of the animation, you simply supply a different target to the composable.

The animate*AsState() functions are the simplest animation APIs in Compose for animating a single value. You only provide the end value (or target value), and the API starts animation from the current value to the specified value.

Now build and run the app. Click on any JoinButton in the app and notice how the background color changes.

Join Button’s Background Animation
Join Button’s Background Animation

The figure shows how the button looks across several frames of the animation. Notice how the icon changes immediately after the click, while the background slowly transitions from one color to another. What’s really impressive here is how easy it was to implement this animation, which makes your app even nicer!

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
Join Button With More Content

The figure shows how you’ll change JoinButton’s appearance in the JoinButtonState.IDLE state.

Before adding any code, analyze how you’ll accomplish this animation. Which properties do you have to animate? To give the button its new look, you need to:

  1. Animate the background, as you did in the previous example.
  2. Change the icon. You’ll change the asset the same way as before, but you’ll improve that change by animating the icon color.
  3. Hide and show the text depending on the state.
  4. Animate the button’s width.

So you need to animate four different properties. Keep that in mind when adding the following code.

Defining the transition

To animate these properties, you’ll use Transition. Transition manages one or more animations as its children and runs them simultaneously between multiple states.

In JoinButton.kt, add the following code to JoinButton(), just below shape:

val transition = updateTransition(
  targetState = buttonState, 
  label = "JoinButtonTransition"
)

Add this import as well:

import androidx.compose.animation.core.updateTransition

updateTransition creates and remembers an instance of Transition and updates its state. When targetState changes, Transition will run all of its child animations towards their target values specified for the new targetState. You’ll add those target values next. You also passed in the label property, which let you inspect and debug those animations in Android Studio.

Next, you’ll define child animations. Replace buttonBackgroundColor definition with the following code:

val duration = 600
val buttonBackgroundColor: Color
  by transition.animateColor(
    transitionSpec = { tween(duration) },
    label = "Button Background Color"
  ) { state ->
    when (state) {
      JoinButtonState.IDLE -> Color.Blue
      JoinButtonState.PRESSED -> Color.White
    }
  }

Add these imports as well:

import androidx.compose.animation.animateColor
import androidx.compose.animation.core.tween

Here, you defined the transition duration and first child animation in your transition. You used animateColor which is one of animate* extension functions that allow you to define a child animation in your transition. You specified the target values for each of the states. These animate* functions return an animation value that is updated every frame during the animation when the transition state is updated with updateTransition.

You also used tween(). With tween(), you created a TweenSpec configured with the given duration, delay and easing curve. Since you only specified a duration, the code uses 0 for delayMillis and FastOutSlowInEasing() for easing.

Easing is a way to adjust an animation’s fraction. The fraction represents how far along the animation you are and its values are within the [0, 1] range, or [0, 100], representing the percent of the animation you finished.

Easing allows transitioning elements to speed up and slow down, rather than moving at a constant, linear, rate.

Next, add the remaining child animations. Below buttonBackgroundColor, add the following code:

val buttonWidth: Dp
  by transition.animateDp(
    transitionSpec = { tween(duration) },
    label = "Button Width"
  ) { state ->
    when (state) {
      JoinButtonState.IDLE -> 70.dp
      JoinButtonState.PRESSED -> 32.dp
    }
  }
val textMaxWidth: Dp
  by transition.animateDp(
    transitionSpec = { tween(duration) },
    label = "Text Max Width"
  ) { state ->
    when (state) {
      JoinButtonState.IDLE -> 40.dp
      JoinButtonState.PRESSED -> 0.dp
    }
  }

Don’t forget to add these imports:

import androidx.compose.ui.unit.Dp
import androidx.compose.animation.core.animateDp

Finally, replace the current iconTintColor definition with this:

val iconTintColor: Color
  by transition.animateColor(
    transitionSpec = { tween(duration) },
    label = "Icon Tint Color"
  ) { state ->
    when (state) {
      JoinButtonState.IDLE -> Color.White
      JoinButtonState.PRESSED -> Color.Blue
    }
  }

Great! You’ve now prepared everything you need for your transition, but you still have to connect this code with the composables you want to animate.

Connecting the transition to the composables

Properties buttonBackgroundColor and iconTintColor are already in place so you don’t have to change that.

Next, replace the Box() definition with the following:

Box(
  modifier = Modifier
    .clip(shape)
    .border(width = 1.dp, color = Color.Blue, shape = shape)
    .background(color = buttonBackgroundColor)
    .size(
      width = 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(    // here
    verticalAlignment = Alignment.CenterVertically
  ) {
    Icon(
      imageVector = iconAsset,
      contentDescription = "Plus Icon",
      tint = iconTintColor,
      modifier = Modifier.size(16.dp)
    )
    Text(   // here
      text = "Join",
      color = Color.White,
      fontSize = 14.sp,
      maxLines = 1,
      modifier = Modifier.widthIn(
        min = 0.dp,
        max = textMaxWidth // here
      )
    )
  }
}

First, notice how you changed Box()’s content. You used a Row() to align an Icon() and a Text() beside one another. Second, notice how you didn’t have to change how you access specific transition properties in Box()’s modifier and how you’re using it for Icon() and Text(). Just like before, you used buttonBackgroundColor to access the button background or iconTintColor to access the icon tint color.

Add the following imports as well.

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.Text
import androidx.compose.ui.unit.sp

And that’s it! This is now a complete button that will animate from one state to another. Build and run the app. You’ll now see the new JoinButton in the posts.

Posts With the Completed Join Button
Posts With the Completed Join Button

Click the button in any of the posts and see how it animates from one state to the other.

Join Button Animation
Join Button Animation

You see how the button’s width and text change as well as the color animations in the button’s background and icon.

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.

Note: At the time of writing, this animation API was in an experimental phase, so keep that in mind when you see @ExperimentalAnimationApi annotations in the code.

In this section, you’ll implement a toast composable that appears when the user joins a subreddit. It will look like this:

Joined Toast
Joined Toast

This toast will appear any time you join a new subreddit, by tapping the JoinButton. There are a few things you need to do, to implement such behavior, so let’s start by creating the initial toast composable.

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(
        painter = painterResource(
          id = R.drawable.ic_planet
        ),
        contentDescription = "Subreddit Icon"
      )
      Spacer(modifier = Modifier.width(8.dp))
      Text(text = "You have joined this community!")
    }
  }
}

@Preview
@Composable
fun JoinedToastPreview() {
  JoinedToast(visible = true)
}

Here’s what the code above does. You used a Box() to give your toast a specific background, shape, size and padding. In the Box(), you added a Row() to align an Icon(), Spacer() and Text().

For this to work, add the following imports as well:

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.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.raywenderlich.android.jetreddit.R

Build the project and check the preview panel to see your composable.

JoinedToast Composable — Preview
JoinedToast Composable — Preview

Awesome! Next, you’ll animate the toast. :]

Animating JoinedToast

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

@ExperimentalAnimationApi
@Composable
fun JoinedToast(visible: Boolean) {
  AnimatedVisibility(
      visible = visible,
      enter = slideInVertically(initialOffsetY = { +40 }) +
          fadeIn(),
      exit = slideOutVertically() + fadeOut()
  ) {
    ToastContent()
  }
}

Android Studio will complain if you don’t add these imports as well.

import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.fadeOut

As mentioned earlier, @ExperimentalAnimationApi is there because this is an experimental API — at least, at the time of writing.

Here, you wrapped ToastContent() with AnimatedVisibility(), which animates the appearance and disappearance of its content as the visible value changes.

This is AnimatedVisibility()’s signature, taken from the Jetpack Compose documentation:

@Composable
fun AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = shrinkOut() + fadeOut(),
    initiallyVisible: Boolean = visible,
    content: @Composable () -> Unit
): Unit

You can define different EnterTransition and ExitTransition in enter and exit for the appearance and disappearance animations. There are three types of EnterTransition and ExitTransition: fade, expand/shrink and slide. By using the + sign, you combine the enter and exit transitions. The combination’s order doesn’t matter since the transition animations start simultaneously.

Now, back to your code. You passed visible from JoinedToast() to AnimatedVisibility(). With that, you’ll control when the animation triggers. When visible changes to true, it triggers the enter animation. Otherwise, it triggers the exit animation.

For the enter transition, you combined two transitions: slideInVertically() and fadeIn(). slideInVertically() slides the content vertically from a starting offset defined in initialOffsetY to 0. You control the direction of the slide by configuring initialOffsetY. A positive initial offset means the animation will slide up, whereas a negative value will slide the content down.

For the exit transition, you used slideOutVertically() and fadeOut().

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().

Start by adding @ExperimentalAnimationApi to JoinedToastPreview():

@ExperimentalAnimationApi
@Preview
@Composable
fun JoinedToastPreview() {
  JoinedToast(visible = true)
}

Next, open HomeScreen.kt and update HomeScreen() like this:

@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)
    }
  }
}

Add the following imports as well:

import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import com.raywenderlich.android.jetreddit.components.JoinedToast
import java.util.Timer
import kotlin.concurrent.schedule

You did a couple of things in the code above. You wrapped a LazyColumn() with a Box(), which allows you to fill the max size of the screen. You also added a second Box() and added JoinedToast() to its content. This second Box() lets you position JoinedToast() at the bottom. Then, you used remember() to define the visibility state of the toast.

Next, you defined the onJoinClickAction. Tapping any JoinButton triggers onJoinClickAction() and displays a toast. After three seconds, you hide the toast by changing isToastVisible to false.

Finally, you used onJoinClickAction as a parameter for the different posts. However, right now, the TextPost() and ImagePost() don’t have an onJoinButtonClick parameter, so you’ll see an error. You’re going to fix that next.

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 = {}
) {
  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)
    }
  }
}

What’s most important here is that you added onJoinButtonClick to the TextPost, ImagePost and Post signatures and passed it down to the Header(). Excellent work! The header already passes onJoinButtonClick to JoinButton() and handles everything, so you don’t have to update those composables. However, because you’re using an experimental animation API, you need to add appropriate annotations to your composables.

Adding experimental annotations

The annotation you have to add is @ExperimentalAnimationApi.

Open JetRedditApp.kt and add @ExperimentalAnimationApi to the following composables:

  1. MainScreenContainer()
  2. AppContent()
  3. JetRedditApp()

You can follow Android Studio errors and use quick actions to easily add these imports. Otherwise, find these three functions and paste the following statement at the top of those functions: @ExperimentalAnimationApi.

Add this import as well:

import androidx.compose.animation.ExperimentalAnimationApi

Finally, open MainActivity.kt and add @ExperimentalAnimationApi to onCreate(), like this:

@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    JetRedditApp(viewModel)
  }
}

Don’t forget to add an import for @ExperimentalAnimationApi as well:

import androidx.compose.animation.ExperimentalAnimationApi

Whew! Now, build and run the app. Click any JoinButton and observe the toast’s enter and exit animations.

Joined Toast
Joined Toast

With that, you used three different APIs to animate your composables. Well done!

Key points

  • You use animate*AsState() 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 and updateTransition() for state-based transitions.
  • Use Transitions 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.

Hopefully, this was a fun ride for you. You had the chance to play with three different APIs to create some simple, yet beautiful animations. What follows is the last chapter of this book. You’ve come a long way indeed!

In the next chapter, you’ll see how to combine the old View framework with Jetpack Compose and how both can coexist in the same codebase.

See you there! :]

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.
© 2026 Kodeco Inc.