Theming
Behaviors
It is common to make a View's behavior and presentation configurable. In many cases this happens through properties like colors, fonts, etc.
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
}
Sometimes a View needs to support more complex customization. Take a TabbedPanel for example. The number of configurations is fairly open-ended; and the API would be needlessly complex if it tried to encompass everything.
This is where a Behavior
comes in handy. Views can offer deep customization by delegating rendering, hit detection and anything else to Behaviors. TabbedPanel--along with TextField and many other controls--actually does this.
Implementing a Behavior
Behaviors offer a few common capabilities that help with View customization. You create one by implementing the Behavior
interface, or a sub-type of it depending on the target View.
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 support installation and uninstallation to and from Views. This gives each Behavior a chance to configure the target View upon first assignment and cleanup when removed.
Delegating to a Behavior
View subtypes need to manage behaviors directly. Kotlin does not have self types, so the View
base class cannot have a behavior<Self>
to make this easier.
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
However, View subtypes can use the behavior
delegate to guarantee proper installation and uninstallation. This delegate also ensures a Behavior's overrides for things like clipCanvasToBounds
or mirrorWhenRightToLeft
are not missed during installation.
Specialized Behaviors
As mentioned before, TabbedPanel
delegates a lot to its Behavior. It actually exposes the fact that it is a Container
to its Behavior. This is done using the TabbedPanelBehavior
sub interface. Classes that implement this interface are able to directly modify their panel's children
and layout
.
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
This provides great flexibility when defining the presentation and behavior for TabbedPanels. You can do similar things with Views in your app.
You can automatically style Views using Themes
Themes
Doodle apps can use Theme
s 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
(Web, Desktop) 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 SpinButton control that manages which theme is selected.
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.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.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 ->
themeManager.selected = when (new) {
Dark -> darkTheme
else -> lightTheme
}
}
private val themePicker = setupThemeControls()
private val list = List(
progression = 1..1000,
itemVisualizer = toString(highlightingTextVisualizer()),
selectionModel = MultiSelectionModel(),
).apply {
cellAlignment = {
it.left eq 2
it.centerY eq parent.centerY
}
}
init {
display.children += themePicker
display.children += ScrollPanel(list).apply {
suggestBounds(Rectangle(300, 200).centered(display.center))
contentWidthConstraints = { it eq parent.width - verticalScrollBarWidth }
Resizer(this, 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 { 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.StyledText
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(StyledText(item), context.selected)
}
}
/**
* Simple Label that tracks whether it is selected.
*/
class HighlightingLabel(styledText: StyledText, selected: Boolean): Label(styledText) {
val selectedChanged: PropertyObservers<HighlightingLabel, Boolean> by lazy { PropertyObserversImpl(this) }
var selected by observable(selected, selectedChanged as PropertyObserversImpl)
}
/**
* 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)
}
}
}
}
}
//sampleEnd
package 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
}
You will need to add the Themes
library to your app's dependencies.
- Kotlin
- Groovy
build.gradle.kts
dependencies {
implementation ("io.nacular.doodle:themes:$doodleVersion")
}
build.gradle
//sampleStart
dependencies {
implementation "io.nacular.doodle:themes:$doodle_version"
}
//sampleEnd
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
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
.
Include behavior modules like this so they can be used as part of a dynamic Theme
.