Skip to main content

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​

Pointer handling uses the same opt-in module pattern from the Applications page.

Module Required

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()) } } //sampleEnd

Hit 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.

Doodle 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.

tip

Override 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 }
tip

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 }
tip

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:

Module Required

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()) } } //sampleEnd

Focus​

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()) } }
tip

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:

  • key is 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.
  • code is 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 }
caution

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:

  1. Sink (the filter phase): ancestors get the first look, top to bottom — Display, then Panel — before the Button itself hears a thing. Register for this with pointerFilter, pointerMotionFilter, or keyFilter. Any of them can consume() the event here and veto it before the Button ever finds out.
  2. Bubble: assuming nobody stepped in, Button — the true target — hears about it through the regular pointerChanged/keyChanged you already know, and the event then climbs back up: Panel, then Display, each notified the same way, stopping the moment anyone calls consume().
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 — PointerModule and KeyboardModule — matching the module pattern from Applications.
  • Attach a PointerListener/KeyListener to pointerChanged/keyChanged to react to events on a specific View. Keyboard input additionally requires that View to hold focus, via FocusManager.
  • Any listener can consume an 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, a render method, and a pointer listener are all it takes to close the loop: state, drawing, and input, working together in one View.

And that's Fundamentals, all of it — nicely done. From here, Transforms is the natural next stop, kicking off Advanced Topics.