Skip to main content

Theming

Coming off Accessibility, the last stop in Advanced Topics, let's start the Component Library with the one idea nearly every control in it depends on: how a View's appearance and behavior get separated from the View itself, and applied consistently across an entire app.

Behaviors​

It's common to want a View's behavior and presentation to be configurable. Plenty of the time, simple properties handle it just fine — colors, fonts, and so on:

package rendering import io.nacular.doodle.controls.text.TextField import io.nacular.doodle.drawing.Color.Companion.Darkgray import io.nacular.doodle.drawing.Color.Companion.White fun example() { //sampleStart val textField = TextField().apply { backgroundColor = Darkgray foregroundColor = White borderVisible = false } //sampleEnd }

But some Views need more than a handful of properties can comfortably express. Take TabbedPanel — the range of ways you might want one to look and behave is wide open, and an API trying to cover all of it would collapse under its own complexity.

This is exactly where a Behavior earns its keep. A View can hand off its rendering, hit-detection, and pretty much everything else to a Behavior, swapping in a different one whenever it wants a different look or feel. TabbedPanel does this, and so do TextField and plenty of other built-in controls.

Implementing a Behavior​

A Behavior offers a handful of common hooks for customizing a View. Build one by implementing Behavior directly, or a more specific subtype when your target View calls for it:

package rendering import io.nacular.doodle.controls.buttons.Button import io.nacular.doodle.core.Behavior import io.nacular.doodle.drawing.Canvas import io.nacular.doodle.geometry.Point //sampleStart class MyBehavior: Behavior<Button> { override fun install (view: Button ) {} override fun render (view: Button, canvas: Canvas) {} override fun contains (view: Button, point : Point ) = point in view.bounds override fun clipCanvasToBounds (view: Button ) = true override fun mirrorWhenRightToLeft(view: Button ) = view.mirrorWhenRightLeft override fun uninstall (view: Button ) {} } //sampleEnd
tip

The methods on Behavior are all optional

Behaviors get installed onto a View and uninstalled from it, which gives each one a clean moment to configure its target on first assignment, and an equally clean moment to tidy up when it's removed.

Delegating to a Behavior​

Here's a small Kotlin wrinkle worth knowing about: the language has no self-types, so the View base class can't offer a built-in behavior<Self> property to make this automatic. Each View subtype manages its own Behavior directly instead:

package rendering import io.nacular.doodle.core.Behavior import io.nacular.doodle.core.View import io.nacular.doodle.core.behavior //sampleStart class AView: View() { // ... var behavior: Behavior<AView>? by behavior() } //sampleEnd

That behavior delegate is there to make it painless — it guarantees proper installation and uninstallation, and makes sure overrides like clipCanvasToBounds or mirrorWhenRightToLeft never get missed along the way.

Specialized Behaviors​

TabbedPanel leans on its Behavior more heavily than most — it actually exposes its own Container nature to it, through a TabbedPanelBehavior sub-interface. Implement that, and you can modify the panel's children and layout directly:

package rendering import io.nacular.doodle.controls.panels.TabbedPanel import io.nacular.doodle.controls.panels.TabbedPanelBehavior import io.nacular.doodle.core.Layout.Companion.simpleLayout import io.nacular.doodle.core.view import io.nacular.doodle.utils.diff.Differences //sampleStart class MyTabbedPanelBehavior: TabbedPanelBehavior<Any>() { override fun install(view: TabbedPanel<Any>) { // children and layout accessible to TabbedPanelBehavior subclasses view += view {} view.layout = simpleLayout { views, min, current, max, insets -> // ... current } } override fun uninstall(view: TabbedPanel<Any>) { view.children.clear() view.layout = null } override fun itemsChanged(panel: TabbedPanel<Any>, differences: Differences<Any>) { // ... } override fun selectionChanged(panel: TabbedPanel<Any>, new: Any?, newIndex: Int?, old: Any?, oldIndex: Int?) { // ... } } fun usage(tabbedPanel: TabbedPanel<Any>) { tabbedPanel.behavior = MyTabbedPanelBehavior() } //sampleEnd

That's a lot of flexibility for defining how a TabbedPanel looks and behaves — and nothing stops you from doing the same for Views of your own.

Themes​

Behaviors solve customization one View at a time. A Theme is what coordinates them across your whole app at once, giving every View a consistent look and feel without you wiring up each one by hand. Doodle ships two out of the box: NativeTheme (Web, Desktop), which styles controls using the system's own default look, and BasicTheme (with light and dark variants), a customizable foundation to build further on.

The following app shows a List of numbers that are visualized as custom Labels. These are all themed dynamically based on the OS dark vs light setting and the SpinButton control that manages which theme is selected.

Library Required

Include the Themes library to your app's dependencies.

dependencies { implementation ("io.nacular.doodle:themes:$doodleVersion") }
tip

Change the dark/light setting in your OS to see the app change in realtime.

package io.nacular.doodle.docs.apps import io.nacular.doodle.application.Application import io.nacular.doodle.controls.MultiSelectionModel import io.nacular.doodle.controls.invoke import io.nacular.doodle.controls.list.List import io.nacular.doodle.controls.panels.ScrollPanel import io.nacular.doodle.core.Display import io.nacular.doodle.core.center import io.nacular.doodle.docs.utils.HighlightingTextVisualizer import io.nacular.doodle.drawing.Color import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.paint import io.nacular.doodle.geometry.Rectangle import io.nacular.doodle.geometry.Size import io.nacular.doodle.geometry.centered import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.layout.constraints.ideal import io.nacular.doodle.theme.ThemeManager import io.nacular.doodle.theme.ThemePicker import io.nacular.doodle.theme.adhoc.DynamicTheme import io.nacular.doodle.theme.adhoc.plus import io.nacular.doodle.user.UserPreferences import io.nacular.doodle.user.UserPreferences.ColorScheme import io.nacular.doodle.user.UserPreferences.ColorScheme.Dark import io.nacular.doodle.utils.Resizer class ThemeApp( display : Display, private val themeManager : ThemeManager, nativeTheme : DynamicTheme, basicTheme : DynamicTheme, basicDarkTheme : DynamicTheme, private val userPreferences: UserPreferences? ): Application { private val lightTheme = nativeTheme + basicTheme private val darkTheme = nativeTheme + basicDarkTheme // Monitor changes to the OS color scheme private val colorSchemeChanged = { _: UserPreferences, _: ColorScheme, new: ColorScheme -> when (new) { Dark -> { themeManager.selected = darkTheme display.fill(Color(0x282828u).paint) } else -> { themeManager.selected = lightTheme display.fill(White.paint) } } } private val themePicker = setupThemeControls() private val list = List( progression = 1..1000, itemVisualizer = HighlightingTextVisualizer(), selectionModel = MultiSelectionModel(), ).apply { cellAlignment = { it.left eq 2 it.size prefers ideal it.centerY eq parent.centerY } } init { themeManager.selectionChanged += { _,_,_ -> updateDisplayBackground(display) } display += themePicker display += ScrollPanel(list).apply { suggestBounds(Rectangle(300, 200).centered(display.center)) contentConstraints = { it.width eq parent.width - verticalScrollBarWidth } Resizer(this, movable = false) } display.layout = constrain(themePicker) { it.top eq parent.bottom - readonly(it.height) - 10 it.size.preserve it.centerX eq parent.centerX } updateDisplayBackground(display) } private fun updateDisplayBackground(display: Display) { display.fill(when (themeManager.selected) { darkTheme -> Color(0x282828u) else -> White }.paint) } //sampleStart private fun setupThemeControls(): ThemePicker { userPreferences?.colorSchemeChanged += colorSchemeChanged themeManager.themes += setOf(lightTheme, darkTheme) themeManager.selected = when (userPreferences?.colorScheme) { Dark -> darkTheme else -> lightTheme } return ThemePicker(themeManager).apply { suggestSize(Size(280, 30)) } } //sampleEnd override fun shutdown() { userPreferences?.colorSchemeChanged -= colorSchemeChanged } }

How themes work​

A Theme implements a small interface that lets it walk the whole View graph and apply style and behavior changes wherever it likes:

package themes import io.nacular.doodle.core.View import io.nacular.doodle.theme.Scene import io.nacular.doodle.theme.Theme //sampleStart class MyTheme: Theme { override fun install(scene: Scene) { // } override fun install(view: View) { // } } //sampleEnd

Doodle calls install the first time a Theme is applied, and again whenever new Views join the Display. Notice it's a Scene being handed over, not a Display directly: recall from Displays that a Desktop app can juggle several Displays at once, one per window. A Scene represents everything currently visible across all of them, which is exactly the scope a Theme needs to style your app consistently no matter how many windows it has open.

ThemeManager​

Themes are handled by the ThemeManager, which gives you a simple way to pick the active one. Inject it into your app to work with Themes at all:

package themes import io.nacular.doodle.application.Application import io.nacular.doodle.application.application import io.nacular.doodle.core.Display import io.nacular.doodle.theme.Theme import io.nacular.doodle.theme.ThemeManager import io.nacular.doodle.theme.native.NativeTheme.Companion.NativeTheme import org.kodein.di.instance //sampleStart class SomeApp(display: Display, manager: ThemeManager, theme: Theme): Application { init { manager.selected = theme // ... } override fun shutdown() {} } fun main() { application(modules = listOf(NativeTheme)) { SomeApp(display = instance(), manager = instance(), theme = instance()) } } //sampleEnd

This installs NativeTheme, available as a ready-made bundle of the same name. That bundle also quietly pulls in ThemeModule, which is what makes ThemeManager available for injection in the first place.

Bundle size​

Here's a trap worth knowing about ahead of time: depending on how you implement one, a Theme can quietly drag a much larger bundle along with it than you'd expect. Take this one, for instance:

package themes import io.nacular.doodle.controls.ProgressBar import io.nacular.doodle.controls.buttons.PushButton import io.nacular.doodle.controls.range.Slider import io.nacular.doodle.core.View import io.nacular.doodle.theme.Scene import io.nacular.doodle.theme.Theme //sampleStart class NaiveTheme: Theme { override fun install(scene: Scene) = scene.forEachView { install(it) } override fun install(view: View) { when (view) { is Slider<*> -> { /* it.behavior = ... */ } is PushButton -> { /* it.behavior = ... */ } is ProgressBar -> { /* it.behavior = ... */ } // ... } } } //sampleEnd
danger

A Theme built this way isn't very portable — its bundle cost is steep, because it hard-codes references to every View and Behavior type it might ever need to handle.

Look at what that costs an app that doesn't even use most of those controls:

package themes import io.nacular.doodle.application.Application import io.nacular.doodle.controls.buttons.PushButton import io.nacular.doodle.core.Display import io.nacular.doodle.theme.Theme import io.nacular.doodle.theme.ThemeManager //sampleStart /** * [NaiveTheme]'s static implementation leads to lots of dependencies that are * not used in [SimpleApp]. This includes the View and Behavior classes it uses. */ class SimpleApp(display: Display, manager: ThemeManager, theme: Theme): Application { init { manager.selected = theme display += PushButton() } override fun shutdown() {} } //sampleEnd

SimpleApp only ever shows a PushButton, yet NaiveTheme still drags in code for Slider and ProgressBar too, since they're all hard-referenced in one when block.

Dynamic themes​

DynamicTheme solves this with a neat trick: instead of hard-coding which Views it handles, it uses dependency injection to discover whatever Behaviors happen to be installed via Kodein modules, and filters that list down to the ones it actually cares about. No hard dependencies on Views or Behaviors your app never touches.

The tradeoff is that Behaviors need explicit registration to be discoverable this way. Doodle's built-in Themes define one module per Behavior — using bindBehavior, which takes a Theme class and binds a DynamicTheme scoped to just the Behaviors associated with it — so you can mix and match freely:

package themes.dynamic import io.nacular.doodle.application.application import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeButtonBehavior import org.kodein.di.instance import themes.SimpleApp //sampleStart fun main() { // DynamicThemes require a list of Behavior modules since the // Theme itself is essentially a Behavior filter. // These modules are added to the list of app modules as follows application(modules = listOf(nativeButtonBehavior(), /* other modules */)) { // The nativeButtonBehavior module also makes ThemeManager and Theme // available by default; so there's no need to use additional modules // for them. SimpleApp(display = instance(), manager = instance(), theme = instance()) } } //sampleEnd

Now SimpleApp has no lingering dependency on ProgressBar or its Behavior — just the button it actually uses.

tip

Define your own Behavior modules the same way, so they can slot into a DynamicTheme too.

Recap​

  • A Behavior lets a View hand off rendering, hit-detection, and more to something swappable — build one with behavior so installation and uninstallation are never missed.
  • A Theme coordinates Behaviors across your whole app, installing onto a Scene (which spans every Display your app has open) rather than a single View.
  • ThemeManager picks which Theme is active.
  • Prefer DynamicTheme over hard-coded, per-type Themes — it keeps your bundle free of Views and Behaviors your app never actually uses.
caution

Forget to give a control a Behavior at all — no custom one, no Theme — and it renders as nothing. It's the single most common "why isn't anything showing up" moment in Doodle, and the next page walks through exactly what that looks like and how to fix it.

Next: on to Where's My View? to see what happens when this step gets skipped — then the Catalog itself.