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

This app (like the others in this tutorial) is created as a multi-platform library, with a multiplatform launcher that depends on it. This is not necessary to use Doodle. You could create a single multiplatform build with the common parts of your app in commonMain etc.. This setup is used here because the app are also launched by an app within DocApps when embedding it like below. Therefore, we need a pure library for the app. This is why there is an app and a runner.

build.gradle.kts

plugins { kotlin("multiplatform") } kotlin { // Defined in buildSrc/src/main/kotlin/Common.kt jsTargets () jvmTargets () wasmJsTargets() sourceSets { commonMain { dependencies { api(libs.coroutines.core) api(libs.doodle.controls ) api(libs.doodle.animation) } } } }
info

Build uses libs.versions.toml file.

Launching on Web and Desktop

Doodle apps can be launched in a few different ways on Web and Desktop. We defined our app logic in a multi-platform TimedCards library (no main functions), so it can be used on both platforms. Notice that we are only using a library here because we also want to run the TimedCards in our documentation app. That app will run it as an embedded Web app, which becomes easier if it is a library. Otherwise, we could have defined our platform main functions directly in the TimedCards module. Instead, we created a separate TimedCardsRunner module that contains our main functions.

main.kt

package io.nacular.doodle.examples import io.nacular.doodle.animation.Animator import io.nacular.doodle.animation.AnimatorImpl import io.nacular.doodle.application.Modules.Companion.FontModule import io.nacular.doodle.application.Modules.Companion.ImageModule import io.nacular.doodle.application.Modules.Companion.KeyboardModule import io.nacular.doodle.application.Modules.Companion.ModalModule import io.nacular.doodle.application.Modules.Companion.PointerModule import io.nacular.doodle.application.application import io.nacular.doodle.coroutines.Dispatchers import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior import org.kodein.di.DI.Module import org.kodein.di.bindSingleton import org.kodein.di.instance /** * Creates a [TimedCardsApp] */ //sampleStart fun main() { application(modules = listOf( FontModule, ImageModule, ModalModule, PointerModule, KeyboardModule, basicLabelBehavior(White), Module(name = "App") { bindSingleton<Animator> { AnimatorImpl(instance(), instance()) } } )) { // load app TimedCardsApp( display = instance(), focusManager = instance(), themeManager = instance(), theme = instance(), images = instance(), fonts = instance(), animate = instance(), textMetrics = instance(), timer = instance(), scheduler = instance(), uiDispatcher = Dispatchers.UI ) } } //sampleEnd

Defining Our 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. Therefore, we will do the same and place ours in commonMain/kotlin/io/nacular/doodle/examples.

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