User Input
Back on Layout, you saw how a View gets its size and position. Now for the other half of building something that actually feels alive: reacting to what the user does.
Three stops ahead:
- Pointer input — clicks, taps, drags, hover
- Keyboard input — key presses and focus
- How events travel — the order Views find out about an event
Pointer input
Pointer handling uses the same opt-in module pattern from the Applications page.
Include the PointerModule (Web, Desktop) in your application to use these features.
package pointerinput
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.PointerModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import org.kodein.di.instance
class MyApp(display: Display): Application {
override fun shutdown() {}
}
//sampleStart
fun main() {
application(modules = listOf(PointerModule)) {
MyApp(instance())
}
}
//sampleEndHit detection
Before Doodle can tell you the pointer clicked a View, it first needs to know the pointer was actually over it. That's contains's job: given a point relative to the View's parent, it reports whether that point falls inside the View. The default implementation just checks against bounds, adjusted for the View's transform.
- Demo
- Code
package io.nacular.doodle.docs.utils
import io.nacular.doodle.core.View
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.circle
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.event.PointerListener
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
//sampleStart
/**
* This view renders a circle and provides precise hit detection for it.
*/
class CircularView(radius: Double): View() {
private val circle = Circle(Point(radius, radius), radius)
private var pointerOver by renderProperty(false)
init {
suggestSize(circle.boundingRectangle.size)
pointerChanged += PointerListener.on(
entered = { pointerOver = true },
exited = { pointerOver = false }
)
}
override fun intersects(point: Point) = point - position in circle
override fun render(canvas: Canvas) {
canvas.circle(circle, color = if (pointerOver) Red opacity 0.5f else Red)
}
}
//sampleEndDoodle also accounts for transforms applied to a View's ancestors when delivering pointer events, so the event still finds the right View even through a transformed parent. The View's own transform is the one piece contains needs to handle itself.
- Demo
- Code
package io.nacular.doodle.docs.utils
import io.nacular.doodle.core.View
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerListener.Companion.on
import io.nacular.doodle.geometry.ConvexPolygon
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.reversed
import io.nacular.doodle.geometry.toPath
//sampleStart
/**
* This view renders a triangular shape and provides precise hit detection for it.
*/
class TriangleView: View() {
private val outerPoly get() = ConvexPolygon(Point(width / 2), Point(width, height), Point(0, height))
private val innerPoly get() = Identity.scale(
around = Point(width / 2, height * 2 / 3),
x = 0.5,
y = 0.5
).invoke(outerPoly.reversed())
private var pointerOver by renderProperty(false)
init {
pointerChanged += on(
entered = { pointerOver = true },
exited = { pointerOver = false }
)
}
/**
* Override [intersects] this instead of [contains] to get [point]
* that is mapped to this view's plane, where intersection logic is
* much simpler.
*/
override fun intersects(point: Point) = (point - position).let {
it in outerPoly && it !in innerPoly
}
override fun render(canvas: Canvas) {
canvas.path(
path = outerPoly.toPath() + innerPoly.toPath(),
fill = if (pointerOver) Black.paint else White.paint
)
}
}
//sampleEndOverride intersects instead of contains directly, unless you specifically want to factor the View's own transform into your hit-detection logic yourself.
Listening for pointer events
Once the module is loaded, any View that's visible and enabled is ready to hear from the pointer. Attach a PointerListener` to its pointerChanged property, and you'll get notified when a pointer:
- Enters the View
- Is pressed within it
- Is released within it
- Is clicked (pressed, then released) within it
- Exits it
package pointerinput
import io.nacular.doodle.core.View
import io.nacular.doodle.event.PointerEvent
import io.nacular.doodle.event.PointerListener
import io.nacular.doodle.event.PointerListener.Companion.on
import io.nacular.doodle.event.PointerListener.Companion.pressed
fun example(view: View) {
//sampleStart
// full interface, if you need several callbacks
view.pointerChanged += object: PointerListener {
override fun pressed(event: PointerEvent) { /* .. */ }
override fun exited (event: PointerEvent) { /* .. */ }
}
// DSL, for just one
view.pointerChanged += pressed { event -> /* .. */ }
// DSL, for a few
view.pointerChanged += on(
pressed = { event -> /* .. */ },
exited = { event -> /* .. */ },
)
//sampleEnd
}
PointerListener has a no-op default for every event, so you only ever implement the ones you actually care about.
pointerChanged behaves like any other observable property you've met so far — attach as many listeners as you like, and remove them whenever you're done.
Consuming events
An event carries two Views along with it: the one it originated from (target), and the one it's currently being delivered to (source). These part ways once an event starts traveling through the hierarchy. Any listener can call consume to stop an event dead in its tracks, before anyone else gets a look at it:
package pointerinput
import io.nacular.doodle.core.View
import io.nacular.doodle.event.PointerListener.Companion.pressed
fun consume(view: View) {
//sampleStart
view.pointerChanged += pressed { event ->
// ... take action based on event
event.consume() // indicate that no other listeners should be notified
}
//sampleEnd
}
Motion and multitouch
Movement gets handled separately from clicks and presses, since it fires far more often — register a PointerMotionListener on `pointerMotionChanged instead, and you'll hear about a pointer moving within a View or dragging across it. Same module, same hit-detection rules, same listener-registration styles you've already got down.
Multitouch works right out of the box. A PointerEvent hands you every active Interaction, not just the one that triggered it. So your app can respond to more than one pointer at once, including those on different Views:
package pointerinput
import io.nacular.doodle.core.View
import io.nacular.doodle.event.PointerMotionListener.Companion.moved
fun multiTouch(view: View) {
//sampleStart
view.pointerMotionChanged += moved { event ->
event.targetInteractions // interactions with this View specifically
event.changedInteractions // the interaction(s) that triggered this event
event.allInteractions // every active interaction in the app
}
//sampleEnd
}
Try moving both boxes at the same time if you are on a mobile device or have multiple pointers.
Keyboard input
The same opt-in module pattern again:
Include the KeyboardModule (Web, Desktop) in your application to use these features.
package keyboard
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.KeyboardModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import org.kodein.di.instance
class MyApp(display: Display): Application {
override fun shutdown() {}
}
//sampleStart
fun main() {
application(modules = listOf(KeyboardModule)) {
MyApp(instance())
}
}
//sampleEndFocus
Keyboard input asks for one more step than pointer input does: a View has to hold focus before it hears a single key event, and only one View can hold it at a time. You control that with FocusManager, which comes bundled right along with KeyboardModule (Web, Desktop):
package focus
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.Modules.Companion.KeyboardModule
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.view
import io.nacular.doodle.focus.FocusManager
import org.kodein.di.instance
class MyApp(display: Display, focusManager: FocusManager): Application {
init {
//sampleStart
val view = view {}
display += view
focusManager.requestFocus(view)
//sampleEnd
}
override fun shutdown() {}
}
fun main() {
application(modules = listOf(KeyboardModule)) {
MyApp(display = instance(), focusManager = instance())
}
}
Some controls — TextField, for instance — manage their own focus automatically when styled with the native theme.
Listening for key events
You already know this shape: attach a KeyListener to a View's keyChanged, and you'll hear about pressed and released — just remember, only while that View actually holds focus.
package keyboard
import io.nacular.doodle.core.View
import io.nacular.doodle.event.KeyListener.Companion.pressed
fun consume(view: View) {
//sampleStart
view.keyChanged += pressed { event ->
// ... take action based on event
event.consume() // same consumable pattern as pointer events
}
//sampleEnd
}
Virtual keys vs. physical keys
A KeyEvent will tell you which key was pressed in two rather different ways:
keyis layout-independent — it tells you which logical key was pressed ("Enter", "A"), the way the user's keyboard layout actually maps it. Use this for almost everything.codeis the physical position on the keyboard, regardless of layout or locale. Reach for this only when position genuinely matters more than meaning — games are the classic case.
package keyboard
import io.nacular.doodle.core.View
import io.nacular.doodle.event.KeyListener.Companion.pressed
import io.nacular.doodle.event.KeyText.Companion.Backspace
import io.nacular.doodle.event.KeyText.Companion.Enter
fun virtualKeys(view: View) {
//sampleStart
view.keyChanged += pressed { event ->
when (event.key) {
Enter -> { /* ... */ }
Backspace -> { /* ... */ }
else -> event.key.text // user-appropriate text, for unnamed keys
}
}
//sampleEnd
}
code ignores keyboard layout and locale entirely — avoid it unless physical position is genuinely what you need.
How events travel: sinking and bubbling
Here's something that might surprise you: pointer and key events don't travel straight to their target. They journey through the whole chain of ancestors on the way there, and then again on the way back. Say someone clicks a Button nested inside a Panel, which sits directly on the Display:
Two phases play out, in this order:
- Sink (the filter phase): ancestors get the first look, top to bottom —
Display, thenPanel— before theButtonitself hears a thing. Register for this withpointerFilter,pointerMotionFilter, orkeyFilter. Any of them canconsume()the event here and veto it before theButtonever finds out. - Bubble: assuming nobody stepped in,
Button— the true target — hears about it through the regularpointerChanged/keyChangedyou already know, and the event then climbs back up:Panel, thenDisplay, each notified the same way, stopping the moment anyone callsconsume().
package pointerinput
import io.nacular.doodle.core.View
import io.nacular.doodle.event.KeyListener.Companion.pressed
import io.nacular.doodle.event.PointerListener.Companion.clicked
fun filter(view: View) {
//sampleStart
view.pointerFilter += clicked { event ->
// sink phase: fires before this view's children are notified
}
view.keyFilter += pressed { event ->
// same idea, for key events
}
//sampleEnd
}
An event sent to an ancestor during either phase keeps the same target throughout (the Button, in our example) — only its source changes, to whichever View currently has eyes on it.
Putting it all together
Rendering and Layout each reached for a fresh example to keep the spotlight on one topic at a time. Let's break that habit and bring HelloApp back — combining everything from this whole section into one small, genuinely interactive app: a View that redraws itself the moment someone clicks it.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.text
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.layout.constraints.constrain
//sampleStart
class ClickCounter: View() {
private var clicks by renderProperty(0) // redraws whenever this changes
init {
pointerChanged += clicked { // react to input
clicks += 1
}
}
override fun render(canvas: Canvas) { // draw the current state
canvas.text("Clicked $clicks times", color = Black)
}
}
class CounterApp(display: Display): Application {
private val clickCounter = ClickCounter()
init {
display += clickCounter
display.layout = constrain(clickCounter) { // use constrain layout to fit display
it.edges eq parent.edges
}
display.fill(White.paint) // draw white background
}
override fun shutdown() {}
}
//sampleEnd
import io.nacular.doodle.application.Modules.Companion.PointerModule
import io.nacular.doodle.application.application
import io.nacular.doodle.docs.apps.CounterApp
import org.kodein.di.instance
//sampleStart
fun main() {
application(modules = listOf(PointerModule)) {
CounterApp(display = instance())
}
}
//sampleEnd
Nothing here is new — take a closer look and you'll recognize every piece. renderProperty is exactly what you saw on Views, render and drawing text with it are exactly what you saw on Canvas and Text, and pointerChanged is exactly what you saw a few sections up. All this example really adds is putting the three of them in one room together. That loop — state changes → the View redraws → the user interacts → state changes again — is the one every interactive Doodle app runs on, no matter how much bigger it eventually gets.
Recap
- Pointer and keyboard input are both opt-in —
PointerModuleandKeyboardModule— matching the module pattern from Applications. - Attach a
PointerListener/KeyListenertopointerChanged/keyChangedto react to events on a specific View. Keyboard input additionally requires that View to hold focus, viaFocusManager. - Any listener can
consumean event to stop it from propagating further. - Events sink through ancestors first (the filter phase, vetoable), reach the target, then bubble back up through the same ancestors.
- A
renderProperty, arendermethod, and a pointer listener are all it takes to close the loop: state, drawing, and input, working together in oneView.
And that's Fundamentals, all of it — nicely done. From here, Transforms is the natural next stop, kicking off Advanced Topics.