Pointer 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.
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 {
size = 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.