Skip to main content

TabStrip Tutorial

We will build a simple app that hosts animating tab selection component in this tutorial. It is inspired by Cuberto's Animated Tabbar and this JS impl. This app will be multi-platform, which means it will run in the browser and as a desktop application.

The main focus will be utilizing Doodle's powerful animation APIs to create smooth transitions with precise timings.

tip

You can also see the full-screen app here: JavaScript, WebAssembly.

Project Setup

The app will use a Kotlin Multiplatform setup, which means we can run it on a range of targets supported by Doodle. The directory structure follows a fairly common layout, with common classes and resources in one source set and platform-specific items in their own.

Directory Layout
  • src
    • commonMain
      • kotlin
  • build.gradle.kts

All source code and resources are located under the src directory.

The application logic itself is located in the common source set (src/commonMain), which means it is entirely reused for each platform. In fact, the same app is used unchanged (just targeting JS) within this documentation.

Doodle apps are built using gradle like other Kotlin apps. The build is controlled by the build.gradle.kts script in the root of the TabStrip directory.

build.gradle.kts

@file:OptIn(ExperimentalWasmDsl::class) import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl //sampleStart plugins { kotlin("multiplatform") application } kotlin { js { browser { binaries.executable() } } // Web (JS ) executable wasmJs { browser { binaries.executable() // Web (WASM) executable applyBinaryen {} // Binary size optimization } } jvm { // Desktop (JVM ) executable compilations.all { kotlinOptions { jvmTarget = "11" } // JVM 11 is needed for Desktop } withJava() } sourceSets { // Source set for all platforms commonMain.dependencies { api(libs.doodle.animation) } // Web (JS) platform source set jsMain.dependencies { implementation(libs.doodle.browser) } // Web (WASM) platform source set val wasmJsMain by getting { dependencies { implementation(libs.doodle.browser) } } // Desktop (JVM) platform source set jvmMain.dependencies { // helper to derive OS/architecture pair when (osTarget()) { "macos-x64" -> implementation(libs.doodle.desktop.jvm.macos.x64 ) "macos-arm64" -> implementation(libs.doodle.desktop.jvm.macos.arm64 ) "linux-x64" -> implementation(libs.doodle.desktop.jvm.linux.x64 ) "linux-arm64" -> implementation(libs.doodle.desktop.jvm.linux.arm64 ) "windows-x64" -> implementation(libs.doodle.desktop.jvm.windows.x64 ) "windows-arm64" -> implementation(libs.doodle.desktop.jvm.windows.arm64) } } } } // Desktop entry point application { mainClass.set("io.nacular.doodle.examples.MainKt") } //sampleEnd // could be moved to buildSrc, but kept here for clarity fun osTarget(): String { val osName = System.getProperty("os.name") val targetOs = when { osName == "Mac OS X" -> "macos" osName.startsWith("Win" ) -> "windows" osName.startsWith("Linux") -> "linux" else -> error("Unsupported OS: $osName") } val targetArch = when (val osArch = System.getProperty("os.arch")) { "x86_64", "amd64" -> "x64" "aarch64" -> "arm64" else -> error("Unsupported arch: $osArch") } return "${targetOs}-${targetArch}" }
info

The gradle build uses gradle version catalogs; see libs.versions.toml file for library info.

The Application

All Doodle apps must implement the Application interface. The framework will then initialize our app via the constructor. Our app will be fairly simple: just create an instance of our calculator and add it to the display.

Doodle apps can be defined in commonMain, since they do not require any platform-specific dependencies (we will do this as well). They can also be launched in a few different ways on Web and Desktop. Use the application function in a platform source-set (i.e. jsMain, jvmMain, etc.) to launch top-level apps. It takes a list of modules to load and a lambda that builds the app. This lambda is within a Kodein injection context, which means we can inject dependencies into our app via instance, provider, etc.

TabStripApp.kt

package io.nacular.doodle.examples import io.nacular.doodle.animation.Animator import io.nacular.doodle.application.Application import io.nacular.doodle.core.Display import io.nacular.doodle.geometry.PathMetrics import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.center import io.nacular.doodle.layout.constraints.constrain /** * Simple app that places a [TabStrip] at the center of the display. */ //sampleStart class TabStripApp(display: Display, animator: Animator, pathMetrics: PathMetrics): Application { init { // creat and display a single TabStrip with(display) { this += TabStrip(animator, pathMetrics).apply { size = Size(375, 100) } layout = constrain(first(), center) } } override fun shutdown() { /* no-op */ } } //sampleEnd
tip

Notice that shutdown is a no-op, since we don't have any cleanup to do when the app closes.

The TabStrip View

This tutorial will implement the TabStrip as a single View that manages its state directly. This lets us focus on the animation logic.

tip

A production version of this control would be more flexible if it let you pass in the items in the tab and configure what each does when clicked.

The TabStrip is composed of a rounded rectangle background (with a drop-shadow), a row of items rendered using paths, an indicator that looks like a wave, and a droplet that appears during a new tab selection. All of these elements are rendered directly onto the TabStrip's canvas, so there are no child Views involved for this approach.

Render Logic

All parts of the view are rendered in TabStrip.render, which is how all Views draw themselves.

override fun render(canvas: Canvas) { val foreGround = (foregroundColor ?: Black).paint val backGround = (backgroundColor ?: White).paint // draw shadow canvas.outerShadow(color = Black opacity 0.1f, blurRadius = 20.0) { // draw background rounded rect canvas.rect(bounds.atOrigin, radius = cornerRadius, fill = backGround) } // draw items items.forEach { item -> val itemScale = 1 - itemScaleChange * item.moveProgress // position and scale the item canvas.transform(Identity. translate(Point(item.x, item.y + itemDipOffset * item.moveProgress)). scale(around = Point(item.width / 2, item.height / 2), itemScale, itemScale)) { when (item.selectionProgress) { 1f -> path(item.selected, fill = foreGround) // fully selected else -> { path(item.deselected, fill = foreGround) if (item.selectionProgress > 0f) { // overlay transition if partially selected val dropletCircle = Circle( center = Point(item.width / 2, item.height - dropLetRadius), radius = dropLetRadius + (max(item.width, item.height) - dropLetRadius) * item.selectionProgress ) // overlay background fill so it seeps through holes in item circle(dropletCircle, fill = backGround) // draw selected item clip to droplet clip(dropletCircle) { path(item.selected, fill = foreGround) } } } } } } canvas.translate(indicatorCenter) { // draw indicator path(indicatorPath, fill = foreGround) if (dropletYAboveIndicator != 0.0) { // draw droplet so that it's top is at the indicator top when dropletYAboveIndicator == 0 circle(Circle( radius = dropLetRadius, center = Point(0, -indicatorHeight + dropLetRadius - dropletYAboveIndicator) ), fill = foreGround) } } }

Notice that the icons and indicator are all drawn after the canvas has been transformed. That is because paths are fixed in space (in our case they are all anchored at 0,0) and moving them around on a Canvas requires a transform.

You can also see that each icon can be in 1 of 3 states: deselected, partially selected, fully selected. In the first and last case, only the respective path is drawn. But in the transitional state, both paths are drawn, with the selected path being clipped to the droplet circle. We also need to change the background color that leaks through the holes of the path. So a filler circle is drawn before the selected path with the current background fill.

info

Many of the parameters used in render are ones we will animate (i.e. icon.moveProgress, icon.selectionProgress, indicatorCenter, dropletYAboveIndicator, etc.). Therefore, animation can simply trigger renders to ensure the TabStrip always reflects the changes on every tick.

Triggering The Animation

This component has a complex set of animations that will trigger in a specific sequence to achieve the final look. We will use an injected Animator names animate to perform all animations. And we will tie animations to click events that select a new item.

The first thing we'll do is track the selectedItem as an observable property so we can trigger animations when it changes:

private var selectedItem by observable(items.first()) { _,selected -> ... }

This item will be initialized to the first item in our list. That list will simply be hard-coded for this tutorial. It tracks data for each item in a simple data object.

private inner class ItemState(val selected: Path, val deselected: Path, var selectionProgress: Float = 0f) { val x get() = position.x val y get() = position.y val width get() = size.width val height get() = size.height val centerX get() = x + width / 2 val size = pathMetrics.size(selected) val atDefaults get() = selectionProgress == 0f && moveProgress == 0f lateinit var position : Point var moveProgress = 0f }

The user is able to change selectedItem by clicking on it with the Pointer. We track this by listening to click events on the view directly and deciding which item is selected. We also listen for pointer move events to handle the dynamic cursor, which shows a Pointer when hovering over a non-selected item.

init { // ... // Listen for item clicks pointerChanged += clicked { event -> getItem(at = event.location)?.let { selectedItem = it cursor = Default } } // Update cursor as pointer moves pointerMotionChanged += moved { event -> cursor = when (getItem(event.location)) { selectedItem, null -> Default else -> Pointer } } }

The result is that clicking on an item other than selectedItem will trigger our observable callback, which is where we do our animation handling.

Animation Timeline

This is what the full animation timeline looks like. The diagram shows the sequence of events and their timings (to scale).

There are a few important things to note about our approach. First is that we are using an animation block and tracking several top-level animations that will run concurrently via animations. This allows us to auto-cancel them whenever a new animation starts. We accomplish this by defining animations as an autoCanceling property

private var animation: Animation<*>? by autoCanceling()
tip

Use the autoCanceling delegate to get free animation cleanup whenever a new value is assigned to an old one.

Secondly, our approach initiates a lot of follow-on animations at the completion of previous animations (bubbles that come after others). These are triggered using the then method on the animation they follow. This allows them to be tracked by our top-level animation block and they are only created when the previous animation completes.

warning

Doodle's animation block captures any animations created while the block is being executed and groups them all under a single animation returned from the block. In the following example, all the animations will be tied to animations, so canceling it cancels everything.

All animations rolled into the result

val animations = animate { 0f to 1f (using tweenFloat(...)) {} a to b (using tweenColor(...)) {} then { ... } subAnimation = animate { ... } }

NOTE that you must use then if you'd like to create subsequent animations at the completions of others. Doing the following will not track the animation created in the completed callback.

Some animations not tracked

val animations = animate { (0f to 1f using tweenFloat(...).invoke {}).apply { completed += { // this one not, since it is created after the animate block is finished a to b using tweenColor(...).invoke {} } } }

Animation Logic

The following code has all of the logic to cancel anything that is ongoing when it fires and setup the timeline.

private var selectedItem by observable(items.first()) { _,selected -> // hide droplet dropletYAboveIndicator = 0.0 // Animation blocks roll all top-level animations (those created while in the block) into a common // parent animation. Canceling that animation cancels all the children. animation = animate { // All deselected items move back to normal items.filter { it != selected && !it.atDefaults }.forEach { deselected -> deselected.moveProgress to 0f using (tweenFloat(linear, itemMoveUpDuration)) { deselected.moveProgress = it } deselected.selectionProgress to 0f using (tweenFloat(linear, itemFillDuration )) { deselected.selectionProgress = it } } // Indicator moves to selected item indicatorCenter.x to selected.centerX using (tweenDouble(easeInOutCubic, slideDuration)) { indicatorCenter = Point(it, height) } then { // Selected item moves down selected.moveProgress to 1f using (tweenFloat(linear, itemMoveDownDuration)) { selected.moveProgress = it } } // Indicator primes as it travels to selected item indicatorHeight to minIndicatorHeight using (tweenDouble(linear, primeDuration)) { indicatorHeight = it } then { // Indicator fires at selected item indicatorHeight to maxIndicatorHeight using (tweenDouble(linear, fireDuration)) { indicatorHeight = it } then { // Indicator height returns to normal indicatorHeight to defaultIndicatorHeight using (tweenDouble(linear, recoilDuration)) { indicatorHeight = it } // Droplet moves up to item dropletYAboveIndicator to dropletMaxY using (tweenDouble(linear, dropletTravelDuration)) { dropletYAboveIndicator = it } then { // Droplet is instantly hidden dropletYAboveIndicator = 0.0 // Selected item moves up selected.moveProgress to 0f using (tweenFloat(linear, itemMoveUpDuration)) { selected.moveProgress = it } // Selected item animates droplet within it selected.selectionProgress to 1f using (tweenFloat(linear, itemFillDuration)) { selected.selectionProgress = it } } } } } }

Rendering On Animation

Our animations change many internal variables as they update. These variables all affect how the control is rendered, and therefore need to trigger re-render so their states are constantly in sync with what the control shows at any moment. One option is to have these variables be renderPropertys. This would trigger a render call whenever any of them is changed. This approach is fine for cases where only a small number of properties will change at a time; then the number of render calls remains low. Doodle does queue calls to rerender, but its best to avoid making those calls to begin with if possible.

So we take a different approach. The Animator interface lets you listen to changes to active animations. These events are fired in bulk whenever any animations change, complete, or are canceled. This is a great way to take action at low cost. The code for this is as follows:

init { // ... // Rerender on animation updates animate.listeners += object: Listener { override fun changed(animator: Animator, animations: Set<Animation<*>>) { rerenderNow() // only called once per animation tick } } // ... }

With this, our component will render itself and listen to pointer events so it can trigger animations.

Potential Improvements

We focused on the animation logic in this tutorial, but there are other things the TabStrip needs to manage given how we've chosen to implement it. These include managing how all the items it draws are positioned relative to its own size.

The approach we chose requires the component to recalculate the offsets of all paths it draws whenever its size changes. That's because everything drawn during render is absolutely positioned on the canvas.

A more production-ready approach might have the component only draw the indicator on its canvas, while having the items as child views. Then it could use a Layout to keep the item positions up to date. Of course, that layout would need to participate in the animation and incorporate the animating values into its positioning logic. But this approach would scale better and allow the control to house a more dynamic set of items.