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
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.
Include the Themes library to your app's dependencies.
dependencies {
implementation ("io.nacular.doodle:themes:$doodleVersion")
}Change the dark/light setting in your OS to see the app change in realtime.
- App
- Helpers
- Launcher
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
}
}package themes
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.controls.theme.CommonLabelBehavior
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.text.AnnotatedString
import io.nacular.doodle.theme.Modules.Companion.bindBehavior
import io.nacular.doodle.theme.basic.BasicTheme.BasicThemeConfig
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicThemeModule
import io.nacular.doodle.utils.PropertyObservers
import io.nacular.doodle.utils.PropertyObserversImpl
import io.nacular.doodle.utils.observable
import org.kodein.di.instance
import io.nacular.doodle.theme.basic.BasicTheme as BasicThemeType
//sampleStart
/**
* Visualizer that converts a String into a HighlightingLabel.
*/
fun highlightingTextVisualizer() = itemVisualizer<String, IndexedItem> { item, previous, context ->
when (previous) {
is HighlightingLabel -> previous.apply {
text = item
selected = context.selected
}
else -> HighlightingLabel(AnnotatedString(item), context.selected)
}
}
/**
* Simple Label that tracks whether it is selected.
*/
class HighlightingLabel(styledText: AnnotatedString, selected: Boolean): Label(styledText) {
val selectedChanged: PropertyObservers<HighlightingLabel, Boolean> by lazy { PropertyObserversImpl(this) }
var selected by observable(selected, selectedChanged as PropertyObserversImpl<HighlightingLabel, Boolean>)
}
/**
* Custom module that installs a special Label Behavior into HighlightingLabels whenever the BasicTheme is selected.
* This behavior ensures the foreground is White whenever the label is selected, and the theme default otherwise.
*/
val highlightingLabelBehavior by lazy {
basicThemeModule(name = "HighlightingLabelBehavior") {
bindBehavior<HighlightingLabel>(BasicThemeType::class) { label ->
label.behavior = object: CommonLabelBehavior(textMetrics = instance()) {
private val selectedChanged = { _: HighlightingLabel, _: Boolean, _: Boolean ->
label.foregroundColor = foregroundColor
}
override val foregroundColor get() = when {
label.selected -> White
else -> instance<BasicThemeConfig>().foregroundColor
}
override fun install(view: Label) {
super.install(view)
label.selectedChanged += selectedChanged
}
override fun uninstall(view: Label) {
label.selectedChanged -= selectedChanged
super.uninstall(view)
}
}
}
}
}
//sampleEndpackage themes
import io.nacular.doodle.application.Modules.Companion.UserPreferencesModule
import io.nacular.doodle.application.application
import io.nacular.doodle.docs.apps.ThemeApp
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.theme.basic.BasicTheme.Companion.BasicTheme
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicButtonBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicListBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicSpinButtonBehavior
import io.nacular.doodle.theme.basic.DarkBasicTheme.Companion.DarkBasicTheme
import io.nacular.doodle.theme.native.NativeTheme.Companion.NativeTheme
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeScrollPanelBehavior
import org.kodein.di.instance
fun launch() {
//sampleStart
val appModules = listOf(
BasicTheme,
NativeTheme,
DarkBasicTheme,
UserPreferencesModule,
basicListBehavior(),
basicLabelBehavior(),
basicButtonBehavior(foregroundColor = Black, insets = 12.0),
basicSpinButtonBehavior(incrementA11yLabel = "Increment", decrementA11yLabel = "Decrement"),
highlightingLabelBehavior,
nativeScrollPanelBehavior(),
)
application(modules = appModules) {
ThemeApp(
display = instance(),
basicTheme = instance(),
nativeTheme = instance(),
themeManager = instance(),
basicDarkTheme = instance(),
userPreferences = instance()
)
}
//sampleEnd
}
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
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.
Define your own Behavior modules the same way, so they can slot into a DynamicTheme too.
Recap
- A
Behaviorlets aViewhand off rendering, hit-detection, and more to something swappable — build one withbehaviorso installation and uninstallation are never missed. - A
Themecoordinates Behaviors across your whole app, installing onto aScene(which spans every Display your app has open) rather than a singleView. ThemeManagerpicks which Theme is active.- Prefer
DynamicThemeover hard-coded, per-type Themes — it keeps your bundle free of Views and Behaviors your app never actually uses.
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.