User input
Pointer handling is easy with Doodle; simply include the PointerModule
when launching your app, and the underlying framework uses it to produce key events.
You must include the PointerModule
(Web, Desktop) in your application in order 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
import rendering.MyApp
//sampleStart
fun main() {
/** Include [PointerModule] when launching your app */
application(modules = listOf(PointerModule)) {
MyApp(instance())
}
}
/**
* Pointer events will fire for this app when launched with [PointerModule]
*/
class MyApp(display: Display): Application {
override fun shutdown() {}
}
//sampleEnd
Doodle uses opt-in modules like this to improve bundle size.
Pointer input
Hit detection
The framework relies on the View
contains
method to determine when the pointer is within a View
's boundaries. This method gets a point in the view's parent reference frame (or the Display's for top-level Views)) and returns whether or not that point intersects the view. The default implementation just checks the point against bounds
and accounts 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)
}
}
//sampleEnd
Support for transforms
Doodle also accounts for transformations applied to the View
's ancestors when delivering pointer events. This means the View
will receive the right notification whenever the pointer intersects its parent despite transformations. Hit detection logic in the View
is then triggered as usual. The View
still needs to take its own transformation into account though, since the given point used in hit detection is within the parent coordinate space. This is automatically handled if the View
implements intersects
or if toLocal
is used when overriding contains
.
- 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
)
}
}
//sampleEnd
Override intersects
instead of contains
unless you want to factor in the view's transform into your hit detection logic.
Pointer listeners
Views are able to receive pointer events once the PointerModule
(Web, Desktop) is loaded, they are visible
and enabled
. You can then attach a PointerListener
to any View
's pointerChanged
property and get notified whenever a pointer does one of the following:
- Enters the View
- Pressed within the View
- Released within the View
- Clicked (Pressed then Released) within the View
- Exits the View
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
// Listen to pressed/exit via interface override
view.pointerChanged += object: PointerListener {
override fun pressed(event: PointerEvent) {
// ..
}
override fun exited(event: PointerEvent) {
// ..
}
}
// Listener to pressed via DSL
view.pointerChanged += pressed { event -> /* .. */ }
// Listen to pressed/exit via DSL
view.pointerChanged += on(
pressed = { event -> /* .. */ },
exited = { event -> /* .. */ },
)
//sampleEnd
}
PointerListener
has no-op defaults for each event, so you only need to implement the ones you need.
Notice that pointerChanged
--like other observable properties--supports many observers and enables you to add/remove an observer any time.
Pointer events
The event provided to PointerListener
s carries information about the View
it originated from (target
), the View
it is sent to (source
), various attributes about the state of the pointers--like buttons pressed--and their locations relative to the target View
.
Pointer events are consumable. This means any observer can call consume
on an event and prevent subsequent listeners from receiving 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
}
Calling consume
during filter will prevent descendants (and the target) from receiving the event
Pointer motion events
Pointer motion events occur whenever a pointer moves within a View. They are treated separately from pointer events because of their high frequency. The PointerModule is also required to enable them. And hit detection follows the same rules as with pointer events.
Registration is different though. You use listen to pointerMotionChanged
and implement PointerMotionListener
.
Pointer motion listeners are notified whenever a pointer:
- Moves within a View
- Drags anywhere while pressed, if the press started in a View
package pointerinput
import io.nacular.doodle.core.View
import io.nacular.doodle.event.PointerEvent
import io.nacular.doodle.event.PointerMotionListener
import io.nacular.doodle.event.PointerMotionListener.Companion.moved
import io.nacular.doodle.event.PointerMotionListener.Companion.on
fun pointerMotion(view: View) {
//sampleStart
// Listen to moved/dragged via interface override
view.pointerMotionChanged += object: PointerMotionListener {
override fun moved(event: PointerEvent) {
// ..
}
override fun dragged(event: PointerEvent) {
// ..
}
}
// Listener to moved via DSL
view.pointerMotionChanged += moved { event -> /* .. */ }
// Listen to moved/dragged via DSL
view.pointerMotionChanged += on(
moved = { event -> /* .. */ },
dragged = { event -> /* .. */ },
)
//sampleEnd
}
Multi-touch
Pointer events support multiple, simultaneous inputs by default. This covers the multi-touch use-case on mobile and other similar scenarios. The PointerEvent
class contains information about all active Interaction
s for the current event. This includes those directed at the event target. Apps are therefore able to incorporate this into their pointer handling.
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 // the set of interactions with the target View
event.changedInteractions // the interactions that changed (triggered) this event
event.allInteractions // all active interactions for the app
}
//sampleEnd
}
Doodle also does not limit simultaneous interactions to a single View. All active interactions will be sent to the appropriate Views and managed concurrently. This means it is possible to drag multiple items at the same time.
Try moving both boxes at the same time if you are on a mobile device or have multiple pointers.
Keyboard input
Key handling is simple with Doodle; simply include the KeyboardModule
when launching your app, and the underlying framework uses it to produce key events.
You must include the KeyboardModule
(Web, Desktop) in your application in order 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
import rendering.MyApp
//sampleStart
fun main() {
/** Include [KeyboardModule] when launching your app */
application(modules = listOf(KeyboardModule)) {
MyApp(instance())
}
}
/**
* Key events will fire for this app when launched with [KeyboardModule]
*/
class MyApp(display: Display): Application {
override fun shutdown() {}
}
//sampleEnd
Doodle uses opt-in modules like this to improve bundle size.
A View must gain focus in order to begin receiving key events. This ensures that only a single View can receive key events at any time within the app.
Use the FocusManager
to control focus. It is included in the KeyboardModule
. Just inject it into your app to begin managing the focus.
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
//sampleStart
fun main() {
application(modules = listOf(KeyboardModule)) {
// FocusManager is available in the KeyboardModule
MyApp(display = instance(), focusManager = instance())
}
}
class MyApp(display: Display, focusManager: FocusManager): Application {
init {
val view = view {}
display += view
// ...
focusManager.requestFocus(view)
// ...
}
override fun shutdown() {}
}
//sampleEnd
Some controls (i.e. TextField) also manage their focus when styled in the native theme
Key Listeners
Views are able to receive key events once the KeyboardModule
(Web, Desktop) is loaded and they have focus
. You can then attach a KeyListener
to any View and get notified whenever it has focus and a key is Pressed or Released.
You get these notifications by registering with a View's keyChanged
property.
package keyboard
import io.nacular.doodle.core.View
import io.nacular.doodle.event.KeyEvent
import io.nacular.doodle.event.KeyListener
import io.nacular.doodle.event.KeyListener.Companion.on
import io.nacular.doodle.event.KeyListener.Companion.pressed
fun example(view: View) {
//sampleStart
// Listen to pressed/released via interface override
view.keyChanged += object: KeyListener {
override fun pressed(event: KeyEvent) {
// ..
}
override fun released(event: KeyEvent) {
// ..
}
}
// Listener to pressed via DSL
view.keyChanged += pressed { event -> /* .. */ }
// Listen to pressed/released via DSL
view.keyChanged += on(
pressed = { event -> /* .. */ },
released = { event -> /* .. */ },
)
//sampleEnd
}
KeyListener
has no-op defaults for the 2 events, so you only need to implement the ones you need.
Notice that keyChanged
--like other observable properties--supports many observers and enables you to add/remove
an observer any time.
Key events
The event provided to key listeners carries information about the View it originated from (source
), and various attributes about the key that was pressed or released.
Key events are consumable. This means any observer can call consume
on the event and prevent subsequent listeners from receiving it.
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() // indicate that no other listeners should be notified
}
//sampleEnd
}
Virtual keys and text
KeyEvent
's key
is a layout independent identifier that tells you which "virtual key" was pressed or which text the key can be translated into. Most key handling use-cases should use this property to compare keys.
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 -> { /* ... */ }
// ...
}
}
view.keyChanged += pressed { event ->
// this will be user-appropriate text when the key pressed is not
// one of the "named" keys (i.e. Tab, Shift, Enter, ...)
event.key.text
}
//sampleEnd
}
Physical keys
Some applications will require the use of "physical" keys instead of virtual ones. This makes sense for games or other apps where the key position on a physical keyboard matters.
This information comes from KeyEvent
's code
.
package keyboard
import io.nacular.doodle.core.View
import io.nacular.doodle.event.KeyCode.Companion.AltLeft
import io.nacular.doodle.event.KeyCode.Companion.AltRight
import io.nacular.doodle.event.KeyCode.Companion.Backspace
import io.nacular.doodle.event.KeyListener.Companion.pressed
fun physicalKeys(view: View) {
//sampleStart
view.keyChanged += pressed { event ->
when (event.code) {
AltLeft -> { /* ... */ }
AltRight -> { /* ... */ }
Backspace -> { /* ... */ }
// ...
}
}
//sampleEnd
}
Physical keys do not take keyboard differences and locale into account; so avoid them if possible
Event sinking and filtering
Pointer and Key events "sink" from ancestors down to their target. The "sink" portion is the first phase of event handling; and it runs before the "bubbling" phase. The root ancestor and all descendants toward the target
View
are notified of the event before the target is during this phase.
This phase can also be considered the filter phase, because it lets ancestors decide which events their children get to handle, since they can "veto" an event before it reaches the intended target.
Listeners can take part in this phase of event handling as well. You do this by registering the following types via their respective properties within View
.
PointerListener
viapointerFilter
PointerMotionListener
viapointerMotionFilter
KeyListener
viakeyFilter
package pointerinput
import io.nacular.doodle.core.View
import io.nacular.doodle.event.KeyListener.Companion.pressed
import io.nacular.doodle.event.PointerListener.Companion.clicked
import io.nacular.doodle.event.PointerMotionListener.Companion.moved
fun filter(view: View) {
//sampleStart
view.pointerFilter += clicked { event ->
// called whenever a pointer is pressed on this
// View or its children, before the target
// is notified
}
view.pointerMotionFilter += moved { event ->
// called whenever a pointer is pressed on this
// View or its children, before the target
// is notified
}
view.keyFilter += pressed { event ->
// called whenever a key is pressed while this
// View or one of its children has focus,
// before the target is notified
}
//sampleEnd
}
Calling consume
during filter will prevent descendants (and the target) from receiving the event
Event bubbling
Event "bubbling" is the second and final phase of event handling. This phase notifies the target View
and then "bubbles" the event up to ancestors of that View
as long as the event remains unconsumed. This means you can listen to all events that happen to the descendants of a View
during this phase as well. And similar to the filter phase, you will only receive events that were not consumed before-hand, which in this case means down the hierarchy.
Bubbling is canceled if any listener calls consume
.
Pointer events for ancestors
Pointer events sent to an ancestors and descendants are slightly different from those sent to the View
. These events continue to have the same target
(View
where the event fired), but their source
changes to the recipient ancestor as they bubble or sink.