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.
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.
- kotlin
- resources
- jsMain
- jvmMain
- wasmJsMain
- 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.
Source code and resources for that are usable for platforms are stored in commonMain
. This app is designed to work on all platforms, so our app code and all logic is found under this directory.
The kotlin
directory is where all code for a platform resides. In this case, we have all the classes for our app, including AnimatingFormApp
and AnimatingForm
.
All of these classes are platform agnostic and used by all targets. This makes our app work on any target Doodle supports.
The resources
directory is where resources for a platform resides. In this case, it includes fonts.
Source code and resources that are needed for Web (JS) target are stored in jsMain
. Our app is platform agnostic except for the launch portion, which is located in the source below this directory.
The Web launch portion of our app is located here in the program's main
function.
Holds the index.html
file that loads the generated JS file produced for the Web (JS) target.
Source code and resources that are needed for Desktop (JVM) target are stored in jvmMain
.
The Desktop launch portion of our app is located here in the program's main
function.
Source code and resources that are needed for Web (WASM) target are stored in wasmJsMain
. Our app is platform agnostic except for the launch portion, which is located in the source below this directory.
The Web launch portion of our app is located here in the program's main
function.
Holds the index.html
file that loads the generated JS file produced for the Web (WASM) target.
The build.gradle.kts
file defines how the app is configured and all its dependencies. The AnimatingForms app uses a multi-platform configuration so it can run on all Doodle supported targets.
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}"
}
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.
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)
}
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.