Media & Pickers
Last stop in the Component Library, coming from Panels & Layout: images, files, and a few specialized pickers for colors and dates.
Less overlap here than earlier pages — the only real either/or is Photo vs. LazyPhoto (already loaded an Image, or not); everything else on this page does something the others don't.
Photo
A bit of a conceptual curiosity worth knowing about: an Image in Doodle isn't actually a View — it's closer to text, something you draw directly onto a Canvas with image, as you saw back on Images. Photo is the simple wrapper that turns an already-loaded Image into a real View, so it can sit in a layout, receive a transform, or do anything else a View can do. Pair it with ImageLoader to get the Image it needs.
- Photo
- Aspect Photo
package controls
import io.nacular.doodle.controls.Photo
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
fun usage(image: Image) {
//sampleStart
Photo(image).apply {
suggestSize(Size(100, 200))
}
//sampleEnd
}package controls
import io.nacular.doodle.controls.Photo
import io.nacular.doodle.core.View.SizeAuditor.Companion.preserveAspect
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
fun aspectPhoto(image: Image) {
//sampleStart
Photo(image).apply {
sizeAuditor = preserveAspect(aspectRatio)
suggestSize(Size(100, 200))
}
//sampleEnd
}Want a Photo (or really any View) to keep its aspect ratio as it resizes? Apply the preserveAspect SizeAuditor from the Layout page — this is exactly the kind of thing that hook exists for.
LazyPhoto
:::caution Experimental This control relies on experimental Coroutine features. :::
Where Photo needs an Image you already have in hand, LazyPhoto takes a Deferred<Image> instead — an image load that's already underway. That lets you wire up a View for an image before it's actually finished loading, with full control over what renders in the meantime: you supply the lambda that draws the loading state for as long as the Deferred is pending.
- Demo
- Usage
package controls
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.RepetitionType.Reverse
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.loop
import io.nacular.doodle.animation.transition.easeInOutCubic
import io.nacular.doodle.animation.tweenColor
import io.nacular.doodle.controls.LazyPhoto
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.lighter
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.image.Image
import io.nacular.doodle.utils.observable
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
class LazyPhoto(image: Deferred<Image>, animate: Animator) {
//sampleStart
val photo: LazyPhoto = LazyPhoto(
pendingImage = image,
initialized = { animation.cancel() }
) {
// custom rendering during load
rect(Rectangle(size = size), fill = color.paint)
}
val baseColor = Lightgray
var color by observable(baseColor) { _,_ -> photo.rerenderNow() }
val animation = animate(
baseColor to baseColor.lighter(0.5f),
loop(tweenColor(easeInOutCubic, 1.5 * seconds), type = Reverse)
) {
// Animate color used for loading state
color = it
}
//sampleEnd
}Since you control that loading-state render yourself, it's a natural fit for Animation — re-render on each frame to show a spinner or pulse while the image is still in flight.
FileSelector
Opens the system's native file picker and reports back the files the user chose — the click-to-browse counterpart to dragging files in, which you saw covered on Drag & Drop. The chosen files (a list of LocalFiles) arrive through its filesLoaded event. NativeTheme (Web, Desktop) styles it by default.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.files.FileSelector
import io.nacular.doodle.geometry.Size
//sampleStart
val fileSelector = FileSelector().apply {
suggestSize(Size(200, 40))
filesLoaded += { _,_,new ->
println("files loaded: $new")
}
}
//sampleEndNativeFileSelectorStyler lets you swap in your own look while keeping the native file-picking behavior intact underneath — the same pattern you saw with HyperLink.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.files.FileSelector
import io.nacular.doodle.controls.files.FileSelectorBehavior
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.stroke
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.inset
import io.nacular.doodle.system.Cursor.Companion.Pointer
//sampleStart
class CustomFileSelectorBehavior(textMetrics: TextMetrics): FileSelectorBehavior {
private val prompt = "Choose File"
private val defaultFileText = "No file chosen"
private val textSize = textMetrics.size(prompt)
private val inset = 10
private val lineOffset = textSize.width + 2 * inset
private val strokeColor = Lightgray
private val strokeThickness = 2.0
private val stroke = strokeColor.stroke(strokeThickness)
override fun install(view: FileSelector) {
super.install(view)
view.cursor = Pointer
view.filesLoaded += { _,_,_ -> view.rerender() }
view.enabledChanged += { _,_,_ -> view.rerender() }
}
override fun render(view: FileSelector, canvas: Canvas) {
val textFill = if (view.enabled) Black.paint else strokeColor.paint
canvas.rect(view.bounds.atOrigin.inset(strokeThickness / 2), radius = 5.0, stroke)
if (view.width > lineOffset + strokeThickness) {
canvas.line(Point(lineOffset, strokeThickness), Point(lineOffset, view.height - strokeThickness), stroke)
}
val files = view.files.joinToString(", ") { it.name }.takeIf { it.isNotBlank() } ?: defaultFileText
val textY = (view.height - textSize.height) / 2
canvas.text(prompt, view.font, Point(inset, textY), textFill)
canvas.text(files, view.font, Point(lineOffset + strokeThickness + inset, textY), textFill)
}
}
//sampleEndpackage controls
import io.nacular.doodle.controls.files.FileSelector
import io.nacular.doodle.controls.files.FileSelectorBehavior
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.system.Cursor.Companion.Pointer
import io.nacular.doodle.theme.native.NativeFileSelectorStyler
fun usage(textMetrics: TextMetrics, fileSelectorStyler: NativeFileSelectorStyler) {
//sampleStart
val fileSelector = FileSelector().apply {
acceptsThemes = false
behavior = fileSelectorStyler(this, CustomFileSelectorBehavior(textMetrics))
}
//sampleEnd
}
NativeFileSelectorStyler is available whenever the nativeFileSelectorBehavior (Web, Desktop) module is included.
ColorPicker
Lets the user choose an RGB Color by picking a hue and an opacity. It's built on the HSL/HSV color-space utilities mentioned back on Paint & Stroke — this is the control those utilities exist to support.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.ColorPicker
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.geometry.Size
fun colorPicker() {
//sampleStart
val picker = ColorPicker(Black).apply {
suggestSize(Size(300))
// ...
}
println("Selected color is ${picker.color}")
picker.changed += { _, old, new ->
println("Color changed from: $old to $new")
}
//sampleEnd
}MonthPanel
Shows a single month as a grid of days, with a date selectable from it. It deliberately doesn't show a weekday header of its own — that's DaysOfTheWeekPanel's job. Its kept separate so MonthPanel stays reusable in layouts a header would get in the way of, like a vertically scrolling calendar where the weekdays stay pinned to the top instead of scrolling past with each month.
Two properties worth knowing about: showAdjacentMonths controls whether days from the previous/next month appear grayed out to fill the grid, and weekStart sets which day the week begins on.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.date.MonthPanel
import io.nacular.doodle.geometry.Size
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
//sampleStart
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val monthPanel = MonthPanel(today, /*itemVisualizer, selectionModel*/).apply {
suggestSize(Size(300))
// ...
}
//sampleEndDaysOfTheWeekPanel
A row of weekday labels, meant to sit alongside MonthPanel as its header — the two are built to be composed together into a full calendar view, which is exactly what the live demo shows.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.date.DaysOfTheWeekPanel
import kotlinx.datetime.DayOfWeek
//sampleStart
val panel = DaysOfTheWeekPanel(weekStart = DayOfWeek.MONDAY).apply {
// ...
}
/*
styled using
basicDaysOfTheWeekPanelBehavior(defaultVisualizer = itemVisualizer { day, previous, _ ->
val text = StyledText(day.name.take(1))
when (previous) {
is Label -> previous.apply { styledText = text }
else -> Label(text)
}
})
*/
//sampleEndStacking DaysOfTheWeekPanel above MonthPanel is a job for GridPanel or a couple of straightforward constraints — nothing calendar-specific required. See this demo for a fuller example with a vertically scrolling calendar built from a List<LocalDate> with a MonthPanel per month.
Custom Calendar
Here's the two combined into something real: a simple vertical calendar, one column wide, built from a List<LocalDate> whose model holds the months of the current year — so the virtualized rendering and view recycling you met back on List carries over for free here too, even across a year's worth of months. Each date in the list is visualized with a small custom View pairing a Label with a MonthPanel, recycled and updated as the calendar scrolls.
The real trick is in that custom View's ItemVisualizer: it hands MonthPanel its own visualizer in turn, which is what controls each day's color and how the selected background renders — visualizers composing into further visualizers, all the way down.
- Demo
- Code
package controls.calendar
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.ListModel
import io.nacular.doodle.controls.SelectionModel
import io.nacular.doodle.controls.SingleItemSelectionModel
import io.nacular.doodle.controls.date.DaysOfTheWeekPanel
import io.nacular.doodle.controls.date.MonthPanel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.Display
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.Color.Companion.Darkgray
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.event.PointerEvent
import io.nacular.doodle.event.PointerListener
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.layout.constraints.center
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.system.SystemInputEvent
import io.nacular.doodle.text.invoke
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.basic.list.basicVerticalListBehavior
import io.nacular.doodle.utils.Resizer
import kotlinx.datetime.Clock
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.monthsUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.math.min
private class CalendarDay(private val today: LocalDate, private var day: LocalDate, private val panel: MonthPanel): View() {
private val label = Label()
init {
styleChanged += { rerender() }
clipCanvasToBounds = false
children += label
layout = constrain(label) {
it.center eq parent.center
}
pointerChanged += object: PointerListener {
private var pressed = false
private var pointerOver = false
override fun entered (event: PointerEvent) { pointerOver = true }
override fun exited (event: PointerEvent) { pointerOver = false }
override fun pressed (event: PointerEvent) { pressed = true }
override fun released(event: PointerEvent) {
if (pointerOver && pressed) {
setOf(day).also {
panel.apply {
when {
SystemInputEvent.Modifier.Ctrl in event.modifiers || SystemInputEvent.Modifier.Meta in event.modifiers -> toggleSelection(it)
SystemInputEvent.Modifier.Shift in event.modifiers && lastSelection != null -> {
selectionAnchor?.let { anchor ->
val current = day
when {
current < anchor -> setSelection((anchor downTo current))
anchor < current -> setSelection((anchor .. current).toSet())
}
}
}
else -> setSelection(it)
}
}
}
}
pressed = false
}
}
update(panel, day)
}
override fun render(canvas: Canvas) {
backgroundColor?.let {
val oneDay = DatePeriod(days = 1)
var left = width / 2
var right = left
if (panel.selected(day - oneDay)) {
left = 0.0
}
if (panel.selected(day + oneDay)) {
right = width
}
val radius = min(width, height) / 2
canvas.circle(Circle(radius = radius, center = Point(width/2, height/2)), fill = it.paint)
canvas.rect(Rectangle(left, height / 2 - radius, right - left + 1, 2 * radius), fill = it.paint)
}
}
private infix fun LocalDate.downTo(other: LocalDate): Set<LocalDate> {
var d = this
val result = mutableSetOf<LocalDate>()
while (d >= other) {
result += d
d -= DatePeriod(days = 1)
}
return result
}
private fun ClosedRange<LocalDate>.toSet(): Set<LocalDate> {
var d = start
val result = mutableSetOf<LocalDate>()
while (d <= endInclusive) {
result += d
d += DatePeriod(days = 1)
}
return result
}
fun update(panel: MonthPanel, day: LocalDate) {
this.day = day
val text = "${day.dayOfMonth}"
val annotatedString = when {
day == today && panel.startDate.month == day.month -> Red
panel.selected(day) -> Black
day.month != panel.startDate.month -> Darkgray
else -> White
} { text }
backgroundColor = when {
panel.selected(day) -> Color.Lightgray
else -> null
}
label.annotatedText = annotatedString
}
}
internal class NamedMonthPanel(
date: LocalDate,
itemVisualizer: ItemVisualizer<LocalDate, MonthPanel> = itemVisualizer { day, previous, panel ->
val text = "${day.dayOfMonth}"
when (previous) {
is Label -> previous.apply { this.text = text }
else -> Label(text)
}.also {
it.enabled = day.month == panel.startDate.month
}
},
selectionModel: SelectionModel<LocalDate>? = null
): View() {
private val header = Label()
val monthPanel = MonthPanel(date, itemVisualizer, selectionModel).apply {
monthChanged += {
updateHeader()
}
acceptsThemes = false
}
private fun updateHeader() {
header.annotatedText = White { "${monthPanel.startDate.month.name.lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }} ${monthPanel.startDate.year}" }
}
init {
updateHeader()
children += listOf(header, monthPanel)
layout = constrain(header, monthPanel) { h, m ->
h.top eq 0
h.left eq 10
h.height.preserve
m.top eq h.bottom
m.width eq parent.width
m.bottom eq parent.bottom strength Strong
}
}
}
private class MonthModel(private val dates: ClosedRange<LocalDate>): ListModel<LocalDate> {
private val LocalDate.firstDayOfMonth: LocalDate get() = LocalDate(year = year, month = month, dayOfMonth = 1)
override val size = dates.start.firstDayOfMonth.monthsUntil(dates.endInclusive.firstDayOfMonth) + 1
override fun contains(value: LocalDate) = value in dates
override fun get(index: Int): Result<LocalDate> {
if (index < 0 || index > size) return Result.failure(IndexOutOfBoundsException())
return Result.success(dates.start.firstDayOfMonth + DatePeriod(months = index))
}
override fun section(range: ClosedRange<Int>): List<LocalDate> {
TODO("Not yet implemented")
}
override fun iterator(): Iterator<LocalDate> = object: Iterator<LocalDate> {
private var index = 0
override fun hasNext() = index < size
override fun next() = this@MonthModel[index++].getOrThrow()
}
}
//sampleStart
class CalendarApp(display: Display, themeManager: ThemeManager, theme: Theme): Application {
private val offsetMonths = 20
private val monthList by lazy {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val sharedSelectionModel = SingleItemSelectionModel<LocalDate>()
io.nacular.doodle.controls.list.List(
model = MonthModel(today - DatePeriod(months = offsetMonths)..today + DatePeriod(months = offsetMonths)),
itemVisualizer = itemVisualizer { date, previous, _ ->
when (previous) {
is NamedMonthPanel -> previous.also { it.monthPanel.setDate(date) }
else -> NamedMonthPanel(
date,
itemVisualizer { day, previous, panel ->
when (previous) {
is CalendarDay -> previous.apply { update(panel, day) }
else -> CalendarDay(today, day, panel)
}
},
selectionModel = sharedSelectionModel
)
}
},
).apply {
behavior = basicVerticalListBehavior(itemHeight = 300.0, numColumns = 1, evenItemColor = Color.Transparent, oddItemColor = Color.Transparent)
cellAlignment = fill
acceptsThemes = false
displayChanged += { _, _, _ ->
scrollTo(offsetMonths)
}
}
}
init {
themeManager.selected = theme
display += object: View() {
init {
clipCanvasToBounds = false
val scrollPanel = ScrollPanel(monthList).apply {
suggestSize(Size(300))
contentConstraints = { it.width eq width - verticalScrollBarWidth }
scrollBarDimensionsChanged += {
relayout()
}
}
children += listOf(DaysOfTheWeekPanel(), scrollPanel)
layout = constrain(children[0], children[1]) { header, scroll ->
header.top eq 0
header.width eq parent.width - scrollPanel.verticalScrollBarWidth
header.height eq 50
scroll.top eq header.bottom
scroll.width eq parent.width
scroll.bottom eq parent.bottom strength Strong
}
suggestBounds(Rectangle(300, 350))
Resizer(this, movable = false)
}
override fun render(canvas: Canvas) {
canvas.outerShadow(vertical = 10.0, blurRadius = 10.0, color = Black.opacity(0.5f)) {
rect(bounds.atOrigin, color = Color(0xccccccu).inverted)
}
}
}
display.layout = constrain(display.first(), center)
}
override fun shutdown() {}
}
//sampleEndThis app uses many UI controls that can be styled using various behavior modules. The following shows how it is configured.
package controls.calendar
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.text.AnnotatedString
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicDaysOfTheWeekPanelBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicMonthPanelBehavior
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeScrollPanelBehavior
import org.kodein.di.instance
//sampleStart
fun main() {
val appModules = listOf(
basicLabelBehavior(),
basicMonthPanelBehavior(),
nativeScrollPanelBehavior(),
basicDaysOfTheWeekPanelBehavior(
background = Transparent.paint,
defaultVisualizer = itemVisualizer { day, previous, _ ->
val text = AnnotatedString(day.name.take(1), foreground = White.paint)
when (previous) {
is Label -> previous.apply { annotatedText = text }
else -> Label(text)
}
}
)
)
application(modules = appModules) {
CalendarApp(
display = instance(),
themeManager = instance(),
theme = instance()
)
}
}
//sampleEndRecap
Images aren't Views —Photowraps an already-loaded one into a View;LazyPhotodoes the same for an in-flightDeferred<Image>, with a loading state you control.FileSelectoris the click-to-browse counterpart to drag-and-drop file handling, reporting files throughfilesLoaded.MonthPanelandDaysOfTheWeekPanelare meant to be composed together into a calendar, rather than used alone — and both are configurable enough (adjacent-month display, week start day) to fit most calendar designs without a custom control.
Next: on to Forms to see several controls come together into one typed, validated unit.