TimedCards Tutorial
This is a simple app that shows the flexibility of Doodle Carousels. It is inspired by Giulio Cuscito's "Timed Cards Opening". This app is multi-platform, which means it will run in the browser and as a desktop application.
The entire app rests on Doodle's powerful Carousel APIs to create the layout and smooth, perfectly-timed transitions.
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
- 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 TimedCardsApp
, Card
, CardPresenter
, ButtonControls
and Utils
.
All of these classes are platform agnostic and used by all targets. This makes our app work on any target Doodle supports.
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 TimedCards 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 TimedCards
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.coroutines.core ) // async resource loading (fonts, images, ...)
api(libs.doodle.controls )
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("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}"
}
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.animation.invoke
import io.nacular.doodle.animation.transition.easeInOutCubic
import io.nacular.doodle.animation.tweenFloat
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.carousel.Carousel
import io.nacular.doodle.controls.carousel.CarouselBehavior
import io.nacular.doodle.controls.carousel.dampedTransitioner
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.core.then
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.event.KeyCode.Companion.ArrowLeft
import io.nacular.doodle.event.KeyCode.Companion.ArrowRight
import io.nacular.doodle.event.KeyListener.Companion.pressed
import io.nacular.doodle.event.PointerListener.Companion.on
import io.nacular.doodle.event.PointerMotionListener.Companion.dragged
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.scheduler.AnimationScheduler
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.time.Timer
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
//sampleStart
class TimedCardsApp(
display : Display,
focusManager: FocusManager,
themeManager: ThemeManager,
theme : Theme,
private val fonts : FontLoader,
private val images : ImageLoader,
private val timer : Timer,
private val animate : Animator,
private val textMetrics : TextMetrics,
private val scheduler : AnimationScheduler,
uiDispatcher: CoroutineDispatcher,
): Application {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private lateinit var appFonts: Fonts
private lateinit var carousel: Carousel<CardData, *>
init {
appScope.launch(uiDispatcher) {
themeManager.selected = theme
appFonts = loadFonts(fonts)
// Carousel containing all the content
carousel = Carousel(
createModel(images),
itemVisualizer { item, previous, context ->
when (previous) {
is Card -> previous.apply { update(item, context) }
else -> Card(item, context, appFonts) { itemSize(carousel.size) }
}
}
).apply {
wrapAtEnds = true
behavior = object: CarouselBehavior<CardData> {
// Presenter controlling which items are shown and how they adjust
// as the Carousel moves between frames.
override val presenter = CardPresenter<CardData> { itemSize(it) }
// Responsible for automatic movement between frames
override val transitioner = dampedTransitioner<CardData>(timer, scheduler) { _,_,_, update ->
animate(0f to 1f, using = tweenFloat(easeInOutCubic, duration = 1.25 * seconds)) {
update(it)
}
}
}
keyChanged += pressed {
when (it.code) {
ArrowLeft -> next () // Move Carousel forward one
ArrowRight -> previous() // Move Carousel back one
}
}
var touchLocation = Origin
// start/stop manual movement of the Carousel on pointer press/release
pointerChanged += on(
pressed = {
touchLocation = toLocal(it.location, it.target)
startManualMove()
it.consume()
},
released = { completeManualMove() },
)
// perform manual movement of the Carousel
pointerMotionChanged += dragged {
if (it.source == this) {
moveManually(toLocal(it.location, it.target) - touchLocation)
it.consume()
}
}
}
val buttonControls = ButtonControls(carousel, textMetrics, appFonts)
display += carousel
display += buttonControls
display.layout = constrain(carousel, buttonControls) { carousel_, controls ->
(carousel_.width eq CAROUSEL_MAX_WIDTH )..Strong
carousel_.height eq carousel_.width * MAIN_ASPECT_RATIO
carousel_.width eq carousel_.height / MAIN_ASPECT_RATIO
carousel_.width lessEq parent.width
carousel_.height lessEq parent.height
carousel_.center eq parent.center
controls.height eq CONTROLS_HEIGHT
controls.bottom eq carousel_.bottom
}.then {
// done after Carousel's size is properly determined in previous block, otherwise itemSize(carousel.size)
// won't be accurate since it will use a captured value before the size is updated
// by the constraint.
buttonControls.x = carousel.center.x - itemSize(carousel.size).width / 2
buttonControls.width = carousel.bounds.right - display.children[1].x
}
focusManager.requestFocus(carousel)
}
}
override fun shutdown() {
appScope.cancel()
}
}
//sampleEnd
Notice that shutdown
is a no-op, since we don't have any cleanup to do when the app closes.
The Card Presenter
This app has a single Carousel that contains almost all of the UI elements. And that Carousel is driven by a custom Presenter
that manages the layout and animation of the cards within it.
The cards in the Carousel can be in 3 key positions. The first position is that of the selected card, which fills the Carousel and has larger text displayed to the left. The first small card is in the second important position. And the cards to the right of the first card a all in the last key position.
The second position is treated as special because it has to support the transition from small to large card. Therefore the CardPresenter
needs to track it directly.
package io.nacular.doodle.examples
import io.nacular.doodle.controls.carousel.Carousel
import io.nacular.doodle.controls.carousel.CarouselBehavior
import io.nacular.doodle.core.View
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.Vector2D
import io.nacular.doodle.geometry.lerp
import io.nacular.doodle.layout.Insets
import kotlin.math.abs
import kotlin.math.min
/**
* Presenter responsible for displaying the contents of the Carousel
*/
//sampleStart
class CardPresenter<T>(private val spacing: Double = 20.0, private val itemSize: (Size) -> Size): CarouselBehavior.Presenter<T>() {
/**
* Determines what is shown in the Carousel
*/
override fun present(
carousel : Carousel<T, *>,
position : Position,
progressToNext : Float,
supplementalViews: List<View>,
items : (at: Position) -> Carousel.PresentedItem?
): Presentation {
var zOrder = 0
val results = mutableListOf<Carousel.PresentedItem>()
val itemSize = itemSize(carousel.size)
var currentBounds = null as Rectangle?
var currentPosition = position as Position?
val mainBounds = Rectangle(carousel.size).inset(-10.0)
// Add selected card
currentPosition?.let(items)?.let {
// Image is outset by 10, and grows as we progress to the next frame
it.bounds = mainBounds.inset(Insets(-progressToNext * itemSize.width * 0.5))
it.zOrder = zOrder++
currentPosition = currentPosition?.next
results += it
}
// Add the first small card
currentPosition?.let(items)?.let { item ->
// bounds when exactly at frame
val bounds = Rectangle(
Point(
(carousel.width - itemSize.width) / 2,
(carousel.height - itemSize.height - CONTROLS_HEIGHT)
),
itemSize
)
// bounds gradually grows toward main bounds
item.bounds = lerp(bounds, mainBounds, min(1f, progressToNext * 2))
// increase item zOrder so they sort properly
item.zOrder = zOrder++
// next item is shifted over towards this slot as progress increases
currentBounds = bounds.run { at(x - progressToNext * (width + spacing)) }
currentPosition = currentPosition?.next
results += item
}
// add the remaining cards to the right of the first small one
do {
currentPosition?.let(items)?.let { item ->
// each item is offset to the right of the second, with a spacing that varies w/ progress
currentBounds = currentBounds?.run {
at(right + spacing + -abs(progressToNext - 0.5f) * 3 * spacing + 3 * spacing / 2)
}
currentBounds?.let { item.bounds = it }
// increase item zOrder so they sort properly
item.zOrder = zOrder++
results += item
} ?: break
} while (
// continue until there are no more items to show
(currentBounds?.right ?: 0.0) + spacing < carousel.size.width &&
currentPosition?.next?.also { currentPosition = it } != null
)
return Presentation(items = results)
}
override fun distanceToNext(
carousel: Carousel<T, *>,
position: Position,
offset : Vector2D,
items : (Position) -> Carousel.PresentedItem?
): Distance = Distance(Vector2D(x = 1), carousel.width)
}
//sampleEnd
This presenter is all it takes to handle all the positioning for the Carousel's contents.
Transitioner
Carousel's also expose a way to manage their automatic movement between frames. This includes changing frames or when a manual move is completed and the Carousel needs to move to a valid frame.
This app uses the DampedTransitioner
with an animation for auto frame selection. This Transitioner provides a critically damped spring behavior to smoothly complete manual movement (which happens after a swipe for example). And the animation provided by our app is used during frame jumps.
behavior = object: CarouselBehavior<CardData> {
override val presenter = CardPresenter<CardData> { itemSize(it) }
override val transitioner = dampedTransitioner<CardData>(timer, scheduler) { _,_,_, update ->
animate(0f to 1f, using = tweenFloat(easeInOutCubic, duration = 1.25 * seconds)) {
update(it)
}
}
}
Carousel Interactions
The app uses a floating View with controls to interact with the Carousel and monitor its progress. The two buttons trigger Carousel.previous()
and Carousel.next()
to switch back and forth between frames. While the progress bar and frame text listen for Carousel.progressChanged
and update accordingly.
Button Controls
package io.nacular.doodle.examples
import io.nacular.doodle.controls.buttons.Button
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.carousel.Carousel
import io.nacular.doodle.controls.theme.CommonButtonBehavior
import io.nacular.doodle.core.View
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.AffineTransform
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.inscribed
import io.nacular.doodle.geometry.inset
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.system.Cursor.Companion.Pointer
import io.nacular.measured.units.Angle
import io.nacular.measured.units.times
import kotlin.math.min
class ButtonControls(carousel: Carousel<*, *>, textMetrics: TextMetrics, private val fonts: Fonts): View() {
private val stroke = Stroke(White opacity 0.75f, 0.5)
// Let pointer pass through everywhere else
override fun contains(point: Point) = (point - position).let { localPoint -> children.any { it.contains(localPoint) } }
init {
//sampleStart
children += leftButton { carousel.previous() } // Skip to the previous frame
children += rightButton { carousel.next () } // Skip to the next frame
// Progress Bar
children += view {
contains = { false }
render = {
val progress = (carousel.nearestItem + carousel.progressToNextItem) / carousel.numItems
rect(bounds.atOrigin, fill = stroke.fill )
rect(Rectangle(progress * width, height), fill = Color(0xE8B950u).paint)
}
carousel.progressChanged += { rerender() }
}
// Frame Number
children += view {
font = fonts.mediumBoldFont
contains = { false }
render = {
val currentIndex = "0${(carousel.nearestItem ) % carousel.numItems + 1}"
val nextIndex = "0${(carousel.nearestItem + 1) % carousel.numItems + 1}"
val size1 = textMetrics.size(currentIndex, font)
val size2 = textMetrics.size(nextIndex, font)
val x1 = (width - size1.width ) / 2 - carousel.progressToNextItem * width
val y1 = (height - size1.height) / 2
val x2 = (width - size2.width ) / 2 - carousel.progressToNextItem * width + width
val y2 = (height - size2.height) / 2
text(currentIndex, at = Point(x1, y1), font = font, fill = White.paint)
text(nextIndex, at = Point(x2, y2), font = font, fill = White.paint)
}
carousel.progressChanged += { rerender() }
}
//sampleEnd
layout = constrain(children[0], children[1], children[2], children[3]) { left, right, progress, text ->
left.left eq 0
left.width eq left.height
left.height eq 45
left.centerY eq parent.centerY
right.left eq left.right + 10
right.width eq left.width
right.height eq left.height
right.centerY eq left.centerY
progress.left eq right.right + 20
progress.right eq text.left - 20
progress.height eq 1.5
progress.centerY eq right.centerY
text.top eq 0
text.width eq 40
text.right eq parent.right - 20
text.height eq parent.height
}
}
private fun leftButton (onFired: (Button) -> Unit) = skipButton(isRightButton = false, onFired)
private fun rightButton(onFired: (Button) -> Unit) = skipButton(isRightButton = true, onFired)
private fun skipButton(isRightButton: Boolean, onFired: (Button) -> Unit) = PushButton().apply {
font = fonts.smallRegularFont
fired += onFired
cursor = Pointer
behavior = buttonRenderer(right = isRightButton)
acceptsThemes = false
return this
}
private fun buttonRenderer(right: Boolean = true) = object: CommonButtonBehavior<Button>(null) {
override fun contains(view: Button, point: Point): Boolean =
point in Circle(view.bounds.center, min(view.width, view.height) / 2)
override fun render(view: Button, canvas: Canvas) {
val circle = Circle(view.bounds.atOrigin.center, min(view.width, view.height) / 2)
val triangle = AffineTransform.Identity.scale(around = circle.center, x = 0.7)(circle.inset(circle.radius * 0.75).inscribed(
3,
if (right) 90 * Angle.degrees else 270 * Angle.degrees
)!!)
val points = listOf(triangle.points[2], triangle.points[0], triangle.points[1])
canvas.circle(circle.inset(stroke.thickness / 2), stroke = stroke)
canvas.path (points, Stroke(stroke.fill, 4 * stroke.thickness))
}
}
}
Cards
The cards shown in the Carousel are very dynamic and change what they show when moving from their small to large size.
package io.nacular.doodle.examples
import io.nacular.doodle.accessibility.ImageRole
import io.nacular.doodle.controls.carousel.CarouselItem
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.View
import io.nacular.doodle.core.container
import io.nacular.doodle.core.then
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.lerp
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.width
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.utils.Dimension
import io.nacular.doodle.utils.TextAlignment
import io.nacular.doodle.utils.observable
import kotlin.math.min
/**
* Data objects that are stored in the Carousel's model. These are converted to [Card]s.
*/
data class CardData(val image: Image, val header: String, val title: String, val clip: Rectangle) {
val width get() = image.width
}
/**
* Represents each item in the Carousel. These are created by the [ItemVisualizer][io.nacular.doodle.controls.ItemVisualizer]
* passed to the Carousel.
*/
//sampleStart
class Card(
private var data : CardData,
private var context : CarouselItem,
private val fonts : Fonts,
private val itemInitialSize: () -> Size,
): View(accessibilityRole = ImageRole()) {
private val smallHeader = header(data.header, fonts.smallBoldFont )
private val smallTitle = title(data.title, fonts.smallBoldFont, -0.8)
private val largeText = container {
+view { render = { rect(bounds.atOrigin, fill = Color.White.paint) } }
+header(data.header, fonts.largeRegularFont )
+title (data.title, fonts.largeBoldFont, -1.0)
layout = constrain(children[0], children[1], children[2]) { block, header, title ->
block.edges eq Rectangle(15, 3)
header.top eq block.bottom + 8
header.left eq block.left
header.width.preserve
header.height.preserve
title.top eq header.bottom + 7
title.left eq 0
title.right eq parent.right
title.height.preserve
}.then {
this.height = children.last().bounds.bottom
}
opacity = 0f
}
private var progress by observable(0f) { _, new ->
largeText.opacity = when {
new >= 0.5f -> (new - 0.5f) * 2
else -> 0f
}
relayout()
rerender()
}
init {
size = data.image.size
enabled = false
clipCanvasToBounds = false
children += container {
+view { render = { rect(bounds.atOrigin, fill = Color.White.paint) } }
+smallHeader
+smallTitle
layout = constrain(children[0], smallHeader, smallTitle) { bar, header, title ->
bar.edges eq Rectangle(7.0, 1.5)
header.top eq bar.bottom + 6
header.left eq bar.left
header.width.preserve
header.height.preserve
title.top eq header.bottom + 7
title.left eq 0
title.right eq parent.right
title.height.preserve
}.then {
this.height = smallTitle.bounds.bottom
}
}
children += largeText
layout = constrain(children[0], children[1]) { smallText, largeText ->
smallText.left eq parent.centerX - itemInitialSize().width / 2 + 10
smallText.right eq parent.right - 10
smallText.bottom eq parent.bottom - 20 + progress * 800
smallText.height.preserve
val largeTextOffset = when {
progress >= 0.5f -> 20 * (1 - (progress - 0.5f) * 2)
else -> 0f
}
largeText.left eq 50
largeText.width eq 500
largeText.centerY eq parent.centerY + 5 + largeTextOffset
largeText.height.preserve
}
updateProgress()
}
/**
* Called when a Card needs to be recycled to display a new [CardData] or [CarouselItem].
*/
fun update(data: CardData, context: CarouselItem) {
this.data = data
this.context = context
updateProgress()
}
override fun render(canvas: Canvas) {
val clip = lerp(data.clip, Rectangle(data.width, data.width * MAIN_ASPECT_RATIO), min(1f, progress * 2f))
val radius = 10.0 * (1 - progress * 1.5)
canvas.outerShadow(blurRadius = 10.0, vertical = 10.0, horizontal = 10.0, color = Black opacity 0.5f) {
val w = height * clip.width/clip.height
image(
image = data.image,
source = clip,
radius = radius,
destination = Rectangle((width - w) / 2, 0.0, w, height)
)
}
}
private fun updateProgress() {
progress = when (context.index) {
context.nearestItem -> 1f
(context.nearestItem + 1) % context.numItems -> context.progressToNextItem
else -> 0f
}
}
private fun header(text: String, font: Font) = Label(text).apply {
this.font = font
letterSpacing = 0.0
}
private fun title(text: String, font: Font, letterSpacing: Double) = Label(text).apply {
this.font = font
this.letterSpacing = letterSpacing
wrapsWords = true
textAlignment = TextAlignment.Start
lineSpacing = 1.1f
fitText = setOf(Dimension.Height)
}
}
//sampleEnd