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
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.
- TimedCards
- TimedCardsRunner
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)
}
}
}
}
plugins {
kotlin("multiplatform")
application
}
kotlin {
jsTargets (executable = true)
wasmJsTargets(executable = true)
jvmTargets ( )
sourceSets {
commonMain {
dependencies {
implementation(project(":TimedCards"))
}
}
jsMain.dependencies {
implementation(libs.doodle.browser)
}
val wasmJsMain by getting {
dependencies {
implementation(libs.doodle.browser)
}
}
jvmMain.dependencies {
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)
}
}
}
}
application {
mainClass.set("MainKt")
}
installFullScreenDemo("Development")
installFullScreenDemo("Production" )
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
.
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