Skip to main content

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.

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 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}" }
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.

TimedCardsApp.kt

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
tip

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

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