Skip to main content

Themes

Doodle apps can use Themes to create a consistent look and behavior across their Views. Doodle has built-in support for a Native and Basic theme (which has a light and dark variant). The NativeTheme styles controls like buttons, text fields, and scroll panels using the system default styles and behaviors. The BasicTheme provides a customizable foundation to further build 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 Spinner control that manages which theme is selected.

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.list.List import io.nacular.doodle.controls.panels.ScrollPanel import io.nacular.doodle.controls.toString import io.nacular.doodle.core.Display import io.nacular.doodle.core.center import io.nacular.doodle.docs.utils.controlBackgroundColor import io.nacular.doodle.docs.utils.highlightingTextVisualizer 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.theme.ThemeManager import io.nacular.doodle.theme.ThemePicker import io.nacular.doodle.theme.basic.BasicTheme import io.nacular.doodle.theme.basic.DarkBasicTheme import io.nacular.doodle.theme.native.NativeTheme import io.nacular.doodle.theme.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.Dimension.Height import io.nacular.doodle.utils.Resizer class ThemeApp( display : Display, private val themeManager : ThemeManager, nativeTheme : NativeTheme, basicTheme : BasicTheme, basicDarkTheme : DarkBasicTheme, private val userPreferences: UserPreferences ): Application { private val darkTheme = nativeTheme + basicDarkTheme private val lightTheme = nativeTheme + basicTheme // Monitor changes to the OS color scheme private val colorSchemeChanged = { _: UserPreferences, _: ColorScheme, new: ColorScheme -> themeManager.selected = when (new) { Dark -> darkTheme else -> lightTheme } } private val themePicker = setupThemeControls() private val list = List( progression = 1..1000, itemVisualizer = toString(highlightingTextVisualizer()), selectionModel = MultiSelectionModel(), fitContent = setOf(Height), ).apply { cellAlignment = { it.left eq 2 it.centerY eq parent.centerY } } init { display.children += themePicker display.children += ScrollPanel(list).apply { bounds = Rectangle(300, 200).centered(display.center) contentWidthConstraints = { it eq this@apply.width - verticalScrollBarWidth } Resizer(this).apply { movable = false } } display.layout = constrain(themePicker) { it.top eq parent.bottom - it.height.readOnly - 10 it.centerX eq parent.centerX } display.fill(controlBackgroundColor.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 { size = Size(280, 30) } } //sampleEnd override fun shutdown() { userPreferences.colorSchemeChanged -= colorSchemeChanged } }
Library Required

You will need to add the Themes library to your app's dependencies.

build.gradle.kts

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

How themes work

Themes implement a simple interface that allows them to process the entire View graph and apply style and behavior changes. The API is as follows:

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 the install method when applying a Theme for the first time, and when Views are added to the Display after installation. It provides the Display and a sequence of displayed Views. The Theme is free to customize both.

ThemeManager

Themes are handles by the ThemeManager. It provides an API for selecting the active Theme. Inject it into your app to work with Themes.

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 app installs the NativeTheme, which is available in a bundle of the same name. That bundle also includes the common ThemeModule, which provides access to the ThemeManager.

Bundle size

Themes can lead to larger bundle sizes than expected depending on how they are implemented. Take the following for example.

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

Themes defined this way are not very portable due to their heavy bundle cost

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

Dynamic themes

Doodle addresses this concern with the DynamicTheme. This Theme uses dependency injection to discover the set of Behaviors that have been installed via Kodein Modules. It can filter that list down to those Behaviors associated with it. This avoids hard dependencies on Views or Behaviors as a result.

DynamicThemes require explicit Behavior registration to work. The built-in Themes define a Module per Behavior to allow arbitrary groupings within apps. These modules are defined using bindBehavior, which takes a Theme class and binds a DynamicTheme that includes behaviors associated with that class.

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

This app no longer has extraneous dependencies on things like ProgressBar and its Behavior.

tip

Include behavior modules like this so they can be used as part of a dynamic Theme.