Skip to main content

AnimatingForm Tutorial

This app was inspired by Selecto's "Diprella Login". This app is multi-platform, which means it will run in the browser and as a desktop application.

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
      • resources
  • 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 AnimatingForm directory.

build.gradle.kts

@file:Suppress("OPT_IN_USAGE") import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 //sampleStart plugins { kotlin("multiplatform") } kotlin { js { browser { binaries.executable() } } // Web (JS ) executable wasmJs { browser { binaries.executable() } } // Web (WASM) executable jvm { // Desktop (JVM ) executable compilerOptions { jvmTarget.set(JVM_11) } // JVM 11 is needed for Desktop mainRun { mainClass.set("io.nacular.doodle.examples.MainKt") } // Desktop entry point } sourceSets { // Source set for all platforms commonMain.dependencies { api(libs.coroutines.core) // async photo loading implementation(libs.doodle.controls ) implementation(libs.doodle.animation) implementation(libs.doodle.themes ) } // Web (JS) platform source set jsMain.dependencies { implementation(libs.doodle.browser) } // Web (WASM) platform source set wasmJsMain.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) } } } } //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.

AnimatingFormApp.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.drawing.Color.Companion.Lightgray import io.nacular.doodle.drawing.Font import io.nacular.doodle.drawing.FontLoader import io.nacular.doodle.drawing.TextMetrics import io.nacular.doodle.drawing.paint import io.nacular.doodle.examples.AnimatingFormApp.AppFonts import io.nacular.doodle.geometry.PathMetrics import io.nacular.doodle.layout.Insets import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.layout.constraints.fill import io.nacular.doodle.theme.Theme import io.nacular.doodle.theme.ThemeManager import io.nacular.doodle.theme.native.NativeTextFieldStyler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** * Simple app that places a [AnimatingForm] at the center of the display. */ //sampleStart class AnimatingFormApp( display : Display, fonts : FontLoader, animator : Animator, textMetrics : TextMetrics, pathMetrics : PathMetrics, theme : Theme, themeManager : ThemeManager, textFieldStyler: NativeTextFieldStyler, ): Application { data class AppFonts(val header: Font, val body: Font, val button: Font, val textField: Font) init { themeManager.selected = theme val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) appScope.launch { // creat and display the animating form with(display) { this += AnimatingForm( fonts = appFonts(fonts), animate = animator, pathMetrics = pathMetrics, textMetrics = textMetrics, textFieldStyler = textFieldStyler, ) layout = constrain(first(), fill(Insets(50.0))) fill(Lightgray.paint) } } } override fun shutdown() { /* no-op */ } } //sampleEnd private suspend fun appFonts(fonts: FontLoader): AppFonts { val headerFont = fonts("gothampro_medium.ttf") { family = "GothamPro Medium" weight = 700 size = 38 }!! val bodyFont = fonts("gothampro_light.ttf") { family = "GothamPro Light" size = 18 }!! val buttonFont = fonts(headerFont) { size = 16 }!! val textFieldFont = fonts(bodyFont) { size = 14 weight = 400 }!! return AppFonts(body = bodyFont, header = headerFont, textField = textFieldFont, button = buttonFont) }
tip

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

View Setup

This app uses a single top-level view with 3 layered children to achieve the animation effect. The root view is the AnimatingForm view. It orchestrates its children to pull everything together. It is also responsible for a small portion of the rendering (the rounded corners and "overflowing shapes").

The first child (defined by the FormSwitcher class) contains the sign-in/up button that serves to switch the forms. It also provides the parallax effect through the way it renders its contents.

The other two children represent the sign-in and sign-up forms. They slide back and forth in lock step as the animation progresses. The illusion is achieved by hiding and showing them based on the animation progress. This switch coincides with all 3 views being aligned and the same size, so the FormSwitcher hides the change seamlessly.