Data Display
Coming from Selection & Text, let's move to controls built for showing a whole collection at once, rather than a single value or piece of text.
Every control here is built on a model (ListModel, TreeModel, and so on) plus an ItemVisualizer that renders each item. That split — data on one side, presentation on the other — is what lets the same control show wildly different kinds of data without you writing a new one each time.
The Dynamic* vs Mutable* types map to readonly (though not constant) to fully editable.
Which one do you actually want?
| Your data is... | Reach for |
|---|---|
| A flat collection, one line each | List |
| A flat collection, needing multiple columns | Table (editable: MutableTable) |
| Specifically key/value pairs | KeyValueTable |
| Naturally nested | Tree |
| Nested, and needing multiple columns | TreeTable |
| Best stepped through one (or a few) at a time | Carousel |
List
The visual analog to the list data structure: a read-only, ordered collection of items with random access, each rendered through an ItemVisualizer. Reach for this as your default whenever "show a collection" is the whole job.
It's memory-efficient by design — only the items actually visible in the viewport get rendered, with Views recycled as the user scrolls rather than rebuilt from scratch. That cache defaults to 0 items beyond what's visible, and you can tune it with the scrollCache property when you create the List.
Need the underlying data to change after the List is built? Plain List assumes a fixed model. DynamicList is the same read-only control but with a model that can change out from under it — handy for something like items streaming in asynchronously. MutableList goes a step further and allows the user to edit it directly.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.SimpleMutableListModel
import io.nacular.doodle.controls.StringVisualizer
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.DynamicList
import io.nacular.doodle.docs.utils.Country
import io.nacular.doodle.docs.utils.CountryView
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.layout.constraints.fill
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
fun example(scope: CoroutineScope, imageLoader: ImageLoader) {
//sampleStart
val model = SimpleMutableListModel<Country>()
scope.launch {
listOf(
"United Kingdom" to "images/197374.svg",
"United States" to "images/197484.svg",
"France" to "images/197560.svg",
"Germany" to "images/197571.svg",
"Spain" to "images/197593.svg",
// ...
).sortedBy { it.first }.map { (name, path) ->
imageLoader.load(path)?.let { image ->
model.add(Country(name, image))
}
}
}
val list = DynamicList(
model,
selectionModel = MultiSelectionModel(),
itemVisualizer = itemVisualizer { item, previous, context ->
when (previous) {
is CountryView -> previous.apply {
update(
index = context.index,
country = item,
selected = context.selected
)
}
else -> CountryView(
StringVisualizer,
index = context.index,
country = item,
selected = context.selected
)
}
}
).apply {
cellAlignment = fill
}
//sampleEnd
}- Columns
- Usage
package controls
import io.nacular.doodle.controls.DynamicListModel
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.VerticalDynamicList
import io.nacular.doodle.docs.utils.Country
import io.nacular.doodle.docs.utils.CountryView
import io.nacular.doodle.layout.constraints.fill
fun verticalList(model: DynamicListModel<Country>, stringVisualizer: ItemVisualizer<String, IndexedItem>) {
//sampleStart
val list = VerticalDynamicList(
model,
selectionModel = MultiSelectionModel(),
itemVisualizer = itemVisualizer { item, previous, context ->
when (previous) {
is CountryView -> previous.apply {
update(
index = context.index,
country = item,
selected = context.selected
)
}
else -> CountryView(
stringVisualizer,
index = context.index,
country = item,
selected = context.selected
)
}
}
).apply {
cellAlignment = fill
}
//sampleEnd
}- Rows
- Usage
package controls
import io.nacular.doodle.controls.DynamicListModel
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.HorizontalDynamicList
import io.nacular.doodle.docs.utils.Country
import io.nacular.doodle.docs.utils.CountryView
import io.nacular.doodle.layout.constraints.fill
fun horizontalList(model: DynamicListModel<Country>, stringVisualizer: ItemVisualizer<String, IndexedItem>) {
//sampleStart
val list = HorizontalDynamicList(
model,
selectionModel = MultiSelectionModel(),
itemVisualizer = itemVisualizer { item, previous, context ->
when (previous) {
is CountryView -> previous.apply {
update(
index = context.index,
country = item,
selected = context.selected
)
}
else -> CountryView(
stringVisualizer,
index = context.index,
country = item,
selected = context.selected
)
}
}
).apply {
cellAlignment = fill
}
//sampleEnd
}Lists delegate rendering ListBehavior or use a Theme since Lists delegates rendering.
Table
List's multi-column sibling — rows of a single, strongly-typed item type <T>, each broken into columns. Every column gets its own CellVisualizer, which is handed just the value for that column (i.e. String for a name column, a Boolean for an "attending" checkbox column) and turns it into a View — columns can even derive an arbitrary value from the row rather than reading a property directly, the way a row-number column would. Reach for Table once a single line of text per item isn't enough.
Same pattern as List: plain Table assumes a fixed model, DynamicTable supports a model that changes after creation, and MutableTable (below) allows editing on top of that.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.BooleanVisualizer.Companion.BooleanVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.invoke
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.table.CellInfo
import io.nacular.doodle.controls.table.Table
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.layout.constraints.center
fun tableExample() {
//sampleStart
data class Person(val name: String, val age: Int, val attending: Boolean)
val textVisualizer = highlightingTextVisualizer()
// Generates a string for each row's index
val indexVisualizer = itemVisualizer<Unit, CellInfo<Person, Unit>> { _, previous, context ->
textVisualizer("${context.index + 1}", previous, context)
}
val data = listOf(
Person(name = "Alice", age = 53, attending = false),
Person(name = "Bob", age = 35, attending = true ),
Person(name = "Jack", age = 8, attending = true ),
Person(name = "Jill", age = 5, attending = false)
)
val table = Table(data, MultiSelectionModel()) {
column(Label("#" ), indexVisualizer ) { minWidth = 50.0; width = 50.0; maxWidth = 150.0; cellAlignment = center }
column(Label("Name" ), { name }, textVisualizer ) { minWidth = 100.0; cellAlignment = { it.left eq 10 } }
column(Label("Age" ), { age }, textVisualizer() ) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = { it.left eq 10 }; headerAlignment = cellAlignment }
column(Label("Attending"), { attending }, BooleanVisualizer) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = center }
}
//sampleEnd
}Tables require a TableBehavior for rendering. BasicTheme provides one.
MutableTable
Table, with editing enabled — cells can be changed in place, and the table can perform CRUD operations or reorder itself (sorting by a column, say) by modifying its own model directly.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.BooleanVisualizer.Companion.BooleanVisualizer
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.StringVisualizer
import io.nacular.doodle.controls.invoke
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.mutableListModelOf
import io.nacular.doodle.controls.table.CellInfo
import io.nacular.doodle.controls.table.MutableTable
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.docs.utils.AccessibleVisualizer
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.layout.constraints.center
fun mutableTableExample() {
//sampleStart
data class Person(val name: String, val age: Int, val attending: Boolean)
val textVisualizer = highlightingTextVisualizer()
// Generates a string for each row's index
val indexVisualizer = itemVisualizer<Unit, CellInfo<Person, Unit>> { _, previous, context ->
textVisualizer("${context.index + 1}", previous, context)
}
val data = mutableListModelOf(
Person(name = "Alice", age = 53, attending = false),
Person(name = "Bob", age = 35, attending = true ),
Person(name = "Jack", age = 8, attending = true ),
Person(name = "Jill", age = 5, attending = false)
)
val table = MutableTable(data, MultiSelectionModel()) {
column(Label("#" ), indexVisualizer ) { minWidth = 50.0; width = 50.0; maxWidth = 150.0; cellAlignment = center }
column(Label("Name" ), { name }, textVisualizer, sortable = true) { minWidth = 100.0; cellAlignment = { it.left eq 10 } }
column(Label("Age" ), { age }, textVisualizer(), sortable = true) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = center }
column(Label("Attending"), { attending }, AccessibleVisualizer(BooleanVisualizer, "Attending"), sortable = true) { minWidth = 100.0; width = 100.0; maxWidth = 150.0; cellAlignment = center }
}
//sampleEnd
}Tables require a TableBehavior for rendering. BasicTheme provides one.
KeyValueTable
A Table specialized for exactly one job: showing key/value pairs, one per row. Reach for this over a general Table whenever what you're actually displaying is closer to a property list than arbitrary tabular data.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.after
import io.nacular.doodle.controls.table.ColumnInfo
import io.nacular.doodle.controls.table.KeyValueTable
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.layout.constraints.Bounds
import io.nacular.doodle.layout.constraints.ConstraintDslContext
fun keyValueTable(numberFormatter: (Int) -> String) {
//sampleStart
val textVisualizer = highlightingTextVisualizer()
val numberVisualizer = textVisualizer.after { it: Int -> numberFormatter(it) }
val data = mapOf(
"New York City, NY" to 8_622_357,
"Los Angeles, CA" to 4_085_014,
"Chicago, IL" to 2_670_406,
"Houston, TX" to 2_378_146,
"Phoenix, AZ" to 1_743_469,
"Philadelphia, PA" to 1_590_402,
"San Antonio, TX" to 1_579_504,
"San Diego, CA" to 1_469_490,
"Dallas, TX" to 1_400_337,
"San Jose, CA" to 1_036_242,
)
val alignment: ConstraintDslContext.(Bounds) -> Unit = {
it.left eq 10
it.centerY eq parent.centerY
}
val table = KeyValueTable(
values = data,
keyColumn = ColumnInfo(Label("City" ), textVisualizer ) { headerAlignment = alignment; cellAlignment = alignment },
valueColumn = ColumnInfo(Label("Population"), numberVisualizer) { headerAlignment = alignment; cellAlignment = alignment },
selectionModel = MultiSelectionModel(),
)
//sampleEnd
}KeyValueTables require a TableBehavior for rendering. BasicTheme provides one.
Tree
The visual analog to a tree data structure — nested, expandable/collapsible items, each rendered through an ItemVisualizer. You need two things to build one: a TreeModel, and an ItemVisualizer.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.tree.SimpleTreeModel
import io.nacular.doodle.controls.tree.Tree
import io.nacular.doodle.controls.tree.rootNode
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
//sampleStart
val root = rootNode("") {
node("Applications")
node("Desktop" )
node("Documents" ) {
node("Image.jpg")
node("Todos.txt")
}
node("Downloads" )
node("Movies" )
node("Music" ) {
node("Track1.mp3")
node("Track2.mp3")
node("Track3.mp3")
node("Track4.mp3")
}
node("Photos" ) {
node("Capture1.jpg")
node("Capture2.jpg")
node("Capture3.jpg")
node("Capture4.jpg")
}
}
val tree = Tree(
model = SimpleTreeModel(root),
itemVisualizer = highlightingTextVisualizer(),
selectionModel = MultiSelectionModel()
)
//sampleEndThis creates a Tree from the nodes defined. This demo also places the Tree in a resizable ScrollPanel; but that code is excluded for simplicity. Trees--like Lists--provide memory optimized rendering.
Trees require a TreeBehavior for rendering. BasicTheme provides one.
DynamicTree is readonly (though its models may change), while MutableTree is read/write.
TreeTable
Tree and Table combined — hierarchical, expandable rows, each broken into columns. Reach for this when your data is naturally nested and needs more than one column per item, which is exactly the combination neither Tree nor Table covers alone. You need 2 things to create a Tree: a TreeModel, and ItemVisualizer.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.table.TreeTable
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.controls.tree.SimpleTreeModel
import io.nacular.doodle.controls.tree.TreeNode
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.measured.units.BinarySize
import io.nacular.measured.units.BinarySize.Companion.bytes
import io.nacular.measured.units.BinarySize.Companion.gigabytes
import io.nacular.measured.units.BinarySize.Companion.kilobytes
import io.nacular.measured.units.BinarySize.Companion.megabytes
import io.nacular.measured.units.Measure
import io.nacular.measured.units.times
import io.nacular.measured.units.toNearest
interface File {
val name: String get() = ""
val size: Measure<BinarySize> get() = 0 * bytes
fun toNode(): TreeNode<File> {
return TreeNode(this)
}
companion object {
operator fun invoke(name: String, size: Measure<BinarySize> = 0 * bytes) = object: File {}
}
}
interface Directory: File {
interface DirectoryBuilder
val children: List<File> get() = TODO("Not yet implemented")
override fun toNode(): TreeNode<File> {
return TreeNode(this, children.map { it.toNode() })
}
companion object {
operator fun invoke(name: String, children: DirectoryBuilder.() -> Unit) = object: Directory {}
}
}
private fun Measure<BinarySize>.toSmallestUnit(): Measure<BinarySize> {
listOf(bytes, kilobytes, megabytes, gigabytes).forEach {
if (this >= 1 * it) return (this `as` it).toNearest(0.1 * it)
}
return this
}
fun treeTable() {
//sampleStart
val root = Directory("") {
File("Applications", 100 * gigabytes)
File("Desktop", 79 * bytes )
Directory("Documents") {
File("Image.jpg", 256 * kilobytes)
File("Todos.txt", 10 * megabytes)
}
Directory("Downloads") {}
File("Movies", 1.8 * gigabytes)
Directory("Music") {
File("Track1.mp3", 3.6 * megabytes)
File("Track2.mp3", 867 * kilobytes)
File("Track3.mp3", 3 * megabytes)
File("Track4.mp3", 12 * megabytes)
}
Directory("Photos") {
File("Capture1.jpg", 105 * megabytes)
File("Capture2.jpg", 87 * megabytes)
File("Capture3.jpg", 1000 * kilobytes)
File("Capture4.jpg", 1.1 * gigabytes)
}
}
val treeTable = TreeTable(
model = SimpleTreeModel(root.toNode()),
selectionModel = MultiSelectionModel()
) {
val textVisualizer = highlightingTextVisualizer()
column(Label("Name"), { name }, textVisualizer) { width = 150.0 }
column(Label("Size"), { "${size.toSmallestUnit()}" }, textVisualizer) {
width = 150.0
cellAlignment = {
it.right eq parent.right - 10
it.width.preserve
it.centerY eq parent.centerY
}
}
}
//sampleEnd
}This creates a Tree from the nodes defined. This demo also places the Tree in a resizable ScrollPanel; but that code is excluded for simplicity. Trees--like Lists--provide memory optimized rendering.
Trees require a TreeBehavior for rendering. BasicTheme provides one.
Carousel
Another visual analog to a list, this time optimized for stepping through items one (or a few) at a time rather than showing them all at once — an image gallery is the classic use case. It's read-only; see DynamicCarousel if you need the items themselves to change at runtime. Like List, it's built for scale — only the items its Presenter actually displays get rendered, with Views recycled as it scrolls, so a Carousel can hold enormous datasets without the performance cost.
Three things go into building one: a ListModel, an ItemVisualizer, and a CarouselBehavior. That Behavior is where the real flexibility lives — it supplies a Presenter (which items are shown, and their bounds, transform, opacity, zOrder) and a Transitioner (how the Carousel moves between items). Mix and match either independently to get a wildly different feel from the same data:
LinearPresenter— the familiar side-by-side slideSlicerPresenter,FlipPresenter,CubePresenter— more elaborate transitions, the cube one leaning on exactly the 3D transforms from Advanced TopicsReflectionPresenter,DissolvePresenter— a reflected "floor" beneath each item, or a cross-fade between them
- Demo
- Usage
package controls
import io.nacular.doodle.accessibility.ImageRole
import io.nacular.doodle.controls.ListModel
import io.nacular.doodle.controls.SimpleListModel
import io.nacular.doodle.controls.carousel.Carousel
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.event.KeyCode.Companion.ArrowLeft
import io.nacular.doodle.event.KeyCode.Companion.ArrowRight
import io.nacular.doodle.event.KeyListener
import io.nacular.doodle.event.PointerListener
import io.nacular.doodle.event.PointerMotionListener
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.ImageLoader
import io.nacular.doodle.utils.Resizer
import io.nacular.doodle.utils.observable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
private class Photo(image: Image): View(accessibilityRole = ImageRole()) {
var image: Image by observable(image) { _,_ ->
update()
}
init {
enabled = false
update()
}
private fun update() {
suggestSize(image.size)
}
override fun render(canvas: Canvas) {
canvas.image(image, destination = bounds.atOrigin)
}
override fun toString() = image.source
}
fun carouselExample(scope: CoroutineScope, images: ImageLoader, focusManager: FocusManager) {
//sampleStart
scope.launch {
val model = SimpleListModel(listOf(1, 2, 3, 4,).map {
images.load("/images/carousel$it.jpg")!!
})
val carousel = Carousel<Image, ListModel<Image>>(model, itemVisualizer { item,previous,_ ->
when (previous) {
is Photo -> previous.apply { image = item }
else -> Photo(item).apply { image = item }
}
}).apply {
suggestSize(model[0].getOrThrow().size.run { Size(width * 0.85, height * 0.85) })
keyChanged += KeyListener.pressed {
when (it.code) {
ArrowLeft -> skip(-1)
ArrowRight -> skip(1 )
}
}
var touchLocation = Origin
pointerChanged += PointerListener.on(
pressed = {
touchLocation = toLocal(it.location, it.target)
startManualMove()
it.consume()
},
released = { completeManualMove() },
clicked = { focusManager.requestFocus(this) }
)
pointerMotionChanged += PointerMotionListener.dragged {
if (it.source == this) {
moveManually(toLocal(it.location, it.target) - touchLocation)
it.consume()
}
}
Resizer(this, movable = false)
}
}
//sampleEnd
}A Presenter isn't limited to the items in your model — ReflectionPresenter's "floor" is a Supplemental View it adds on top, for exactly this kind of extra decoration a Presenter wants to contribute beyond what the model provides.
Carousel-based Calendar
A Carousel is also a genuinely nice fit for a calendar: this demo uses one with LinearPresenter over a list of month-start dates, showing one or two months side by side depending on how much width is available.
- Demo
- Usage
package controls.carouselcalendar
import controls.calendar.NamedMonthPanel
import controls.today
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.controls.carousel.Carousel
import io.nacular.doodle.controls.carousel.CarouselBehavior
import io.nacular.doodle.controls.carousel.CarouselBehavior.Transitioner
import io.nacular.doodle.controls.carousel.LinearPresenter
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.docs.apps.MonthModel
import io.nacular.doodle.docs.utils.RangeSelectionModel
import io.nacular.doodle.utils.Pausable
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.times
import kotlinx.datetime.LocalDate
fun carouselCalendar(
animate : Animator,
startDate : LocalDate,
endDate : LocalDate,
spacing : Double,
singleMonthSize: Double,
selectionModel : RangeSelectionModel<LocalDate>,
) {
//sampleStart
Carousel(
MonthModel(startDate..endDate),
itemVisualizer { item, recycled, _ ->
// buttons and days-of-week are Views overlaid on the Carousel for simplicity
when (recycled) {
is NamedMonthPanel -> recycled.also { it.monthPanel.setDate(item) }
else -> NamedMonthPanel(
item,
itemVisualizer { day, recycled, month ->
when (recycled) {
is CalendarDay -> recycled.apply { update(month, day) }
else -> CalendarDay(today, day, month, selectionModel)
}
},
selectionModel = selectionModel, // Months all share the same SelectionModel
)
}
}
).apply {
behavior = object : CarouselBehavior<LocalDate> {
// LinearPresenter controls how months are shown
override val presenter =
LinearPresenter<LocalDate>(spacing = { spacing }) {
it.left eq 0
it.height eq parent.height
when {
parent.width.value > singleMonthSize -> it.width eq max(
0.0,
(parent.width - spacing) / 2
)
else -> it.width eq parent.width
}
}
// tween animation between frames
override val transitioner = object : Transitioner<LocalDate> {
override fun transition(
carousel: Carousel<LocalDate, *>,
startItem: Int,
endItem: Int,
update: (progress: Float) -> Unit
): Pausable = animate(0f to 1f, using = tweenFloat(easeInOutCubic, duration = 250 * milliseconds)) {
update(it)
}
}
}
}
//sampleEnd
}Recap
Listis the default for showing a collection; reach forTableonce one line of text per item stops being enough, andKeyValueTablespecifically for property-style key/value data.Treehandles nested data;TreeTableis what you want when that nested data also needs multiple columns.Carouselis built for stepping through items rather than showing them all — think galleries, not lists.- Every control here is model +
ItemVisualizer— swap either independently to change what's shown or how it looks.
Next: on to Panels & Layout for arranging other Views.