diff --git a/compose/src/commonMain/kotlin/ElementView.kt b/compose/src/commonMain/kotlin/ElementView.kt index d7990feb..26444e7d 100644 --- a/compose/src/commonMain/kotlin/ElementView.kt +++ b/compose/src/commonMain/kotlin/ElementView.kt @@ -7,18 +7,28 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType.Companion.Enter +import androidx.compose.ui.input.pointer.PointerEventType.Companion.Exit +import androidx.compose.ui.input.pointer.PointerEventType.Companion.Move +import androidx.compose.ui.input.pointer.PointerEventType.Companion.Press +import androidx.compose.ui.input.pointer.PointerEventType.Companion.Release +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.pointerInput import com.juul.krayon.element.RootElement import com.juul.krayon.element.UpdateElement +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive import kotlinx.datetime.Clock @Composable @@ -73,15 +83,55 @@ public fun ElementView( } } } + + // Dirty hack for correctly handling hover after taps. Android doesn't always propagate the + // release event properly, instead sending a "move" event even after the release happens, + // and while Compose's internal tap recognition works around that you can't reuse the + // implementation if you need to receive move events while waiting for it. However, we can + // still leverage their workaround by setting a flag on tap and treating our event as a + // different one. This is only necessary for touch events - the desktop user with a mouse + // should maintain hover state after releasing a tap. + var treatNextTouchAsExit by remember { mutableStateOf(false) } Kanvas( Modifier .matchParentSize() - .pointerInput(null) { + .pointerInput(null) { // hover events must be processed manually + while (currentCoroutineContext().isActive) { + val event = awaitPointerEventScope { awaitPointerEvent(PointerEventPass.Main) } + val change = event.changes.last() + if (treatNextTouchAsExit) { + treatNextTouchAsExit = false + if (change.type == PointerType.Touch) { + root.onHoverEnded() + } + } else { + when (event.type) { + Press, Enter, Move -> { + val (x, y) = change.position + root.onHover(isPointInPath(), x.toDp().value, y.toDp().value) + } + Release -> { + // Sometimes this even doesn't fire correctly and we get a move + // instead. Still, handle it properly in case they ever fix it. + if (change.type == PointerType.Touch) { + root.onHoverEnded() + } else { + val (x, y) = change.position + root.onHover(isPointInPath(), x.toDp().value, y.toDp().value) + } + } + Exit -> root.onHoverEnded() + } + } + } + } + .pointerInput(null) { // tap events have such nice syntax sugar detectTapGestures( onTap = { offset -> val x = offset.x.toDp().value val y = offset.y.toDp().value root.onClick(isPointInPath(), x, y) + treatNextTouchAsExit = true }, ) }, diff --git a/element-view/api/element-view.api b/element-view/api/element-view.api index f6291d66..e61655b6 100644 --- a/element-view/api/element-view.api +++ b/element-view/api/element-view.api @@ -3,6 +3,7 @@ public final class com/juul/krayon/element/view/ElementView : android/view/View public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAdapter ()Lcom/juul/krayon/element/view/ElementViewAdapter; + public fun onHoverEvent (Landroid/view/MotionEvent;)Z public fun onTouchEvent (Landroid/view/MotionEvent;)Z public final fun setAdapter (Lcom/juul/krayon/element/view/ElementViewAdapter;)V } diff --git a/element-view/src/androidMain/kotlin/ElementView.kt b/element-view/src/androidMain/kotlin/ElementView.kt index 9cf143c0..33302e16 100644 --- a/element-view/src/androidMain/kotlin/ElementView.kt +++ b/element-view/src/androidMain/kotlin/ElementView.kt @@ -3,8 +3,17 @@ package com.juul.krayon.element.view import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas +import android.graphics.RectF import android.util.AttributeSet import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL +import android.view.MotionEvent.ACTION_DOWN +import android.view.MotionEvent.ACTION_HOVER_ENTER +import android.view.MotionEvent.ACTION_HOVER_EXIT +import android.view.MotionEvent.ACTION_HOVER_MOVE +import android.view.MotionEvent.ACTION_MOVE +import android.view.MotionEvent.ACTION_UP +import android.view.MotionEvent.TOOL_TYPE_FINGER import android.view.View import android.graphics.Paint as AndroidPaint @@ -44,10 +53,40 @@ public class ElementView @JvmOverloads constructor( @SuppressLint("ClickableViewAccessibility") // Can't use recommended `performClick` because we need touch coordinates. override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - adapter?.onClick(event.x, event.y) + val bounds = RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat()) + if (!bounds.contains(event.x, event.y)) { + // We're receiving a touch event that's off of our bounds. This means that the user has + // dragged until they are no longer on the view. + adapter?.onHoverEnded() + return false } - return false + + when (event.actionMasked) { + ACTION_DOWN, ACTION_MOVE -> { + adapter?.onHover(event.x, event.y) + } + ACTION_UP -> { + adapter?.onClick(event.x, event.y) + if (event.getToolType(0) == TOOL_TYPE_FINGER) { + // Touch loses hover when click ends, but other input types like mouse don't. + adapter?.onHoverEnded() + } + } + ACTION_CANCEL -> { + adapter?.onHoverEnded() + } + } + return true + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + ACTION_HOVER_ENTER, ACTION_HOVER_MOVE -> adapter?.onHover(event.x, event.y) + ACTION_HOVER_EXIT -> adapter?.onHoverEnded() + // Should be unreachable, but if they add a new event in the future we shouldn't consume it. + else -> return false + } + return true } override fun onDraw(canvas: Canvas) { diff --git a/element-view/src/androidMain/kotlin/ElementViewAdapter.kt b/element-view/src/androidMain/kotlin/ElementViewAdapter.kt index faebe619..3aad58e2 100644 --- a/element-view/src/androidMain/kotlin/ElementViewAdapter.kt +++ b/element-view/src/androidMain/kotlin/ElementViewAdapter.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext @@ -48,14 +47,27 @@ public class ElementViewAdapter( state.value = state.value.copy(root = RootElement(), width = width, height = height) } - internal fun onClick(x: Float, y: Float): Boolean { + internal fun onClick(x: Float, y: Float) { val state = state.value if (state.view != null) { val scalingFactor = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, state.view.resources.displayMetrics) val isPointInPath = ScaledIsPointInPath(scalingFactor) - return state.root.onClick(isPointInPath, x, y) + state.root.onClick(isPointInPath, x, y) } - return false + } + + internal fun onHover(x: Float, y: Float) { + val state = state.value + if (state.view != null) { + val scalingFactor = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, state.view.resources.displayMetrics) + val isPointInPath = ScaledIsPointInPath(scalingFactor) + state.root.onHover(isPointInPath, x, y) + } + } + + internal fun onHoverEnded() { + val state = state.value + state.root.onHoverEnded() } /** Enqueue rendering in a new scope. */ diff --git a/element-view/src/jsMain/kotlin/ElementViewAdapter.kt b/element-view/src/jsMain/kotlin/ElementViewAdapter.kt index b16d372c..8aeb9b38 100644 --- a/element-view/src/jsMain/kotlin/ElementViewAdapter.kt +++ b/element-view/src/jsMain/kotlin/ElementViewAdapter.kt @@ -46,6 +46,18 @@ public class ElementViewAdapter( state.root.onClick(kanvas, x, y) } + internal fun onHover(x: Float, y: Float) { + val state = state.value + val canvas = state.view ?: return + val kanvas = HtmlKanvas(canvas) + state.root.onHover(kanvas, x, y) + } + + internal fun onHoverEnded() { + val state = state.value + state.root.onHoverEnded() + } + /** Enqueue rendering in a new scope. */ internal fun onAttached(view: HTMLCanvasElement) { state.value = AdapterState(view, RootElement(), view.width, view.height) diff --git a/element-view/src/jsMain/kotlin/HTMLCanvasElement.kt b/element-view/src/jsMain/kotlin/HTMLCanvasElement.kt index 03863cec..0f3fc3ea 100644 --- a/element-view/src/jsMain/kotlin/HTMLCanvasElement.kt +++ b/element-view/src/jsMain/kotlin/HTMLCanvasElement.kt @@ -29,10 +29,16 @@ public fun HTMLCanvasElement.attachAdapter( adapters[this] = adapter adapter.onAttached(this) onclick = { adapter.onClick(it.offsetX.toFloat(), it.offsetY.toFloat()) } + onmouseover = { adapter.onHover(it.offsetX.toFloat(), it.offsetY.toFloat()) } + onmousemove = { adapter.onHover(it.offsetX.toFloat(), it.offsetY.toFloat()) } + onmouseleave = { adapter.onHoverEnded() } } public fun HTMLCanvasElement.detachAdapter() { observers.remove(this)?.unobserve(this) adapters.remove(this)?.onDetached() onclick = null + onmouseover = null + onmousemove = null + onmouseleave = null } diff --git a/element/api/element.api b/element/api/element.api index 9d7e1f23..817bf505 100644 --- a/element/api/element.api +++ b/element/api/element.api @@ -1,17 +1,15 @@ -public final class com/juul/krayon/element/CircleElement : com/juul/krayon/element/Element, com/juul/krayon/element/Interactable { +public final class com/juul/krayon/element/CircleElement : com/juul/krayon/element/InteractableElement { public static final field Companion Lcom/juul/krayon/element/CircleElement$Companion; public fun ()V public fun draw (Lcom/juul/krayon/kanvas/Kanvas;)V public final fun getCenterX ()F public final fun getCenterY ()F public fun getInteractionPath ()Lcom/juul/krayon/kanvas/Path; - public fun getOnClick ()Lkotlin/jvm/functions/Function1; public final fun getPaint ()Lcom/juul/krayon/kanvas/Paint; public final fun getRadius ()F public fun getTag ()Ljava/lang/String; public final fun setCenterX (F)V public final fun setCenterY (F)V - public fun setOnClick (Lkotlin/jvm/functions/Function1;)V public final fun setPaint (Lcom/juul/krayon/kanvas/Paint;)V public final fun setRadius (F)V } @@ -23,6 +21,10 @@ public final class com/juul/krayon/element/CircleElement$Companion : com/juul/kr public synthetic fun trySelect (Lcom/juul/krayon/element/Element;)Lcom/juul/krayon/element/Element; } +public abstract interface class com/juul/krayon/element/ClickHandler { + public abstract fun onClick (Ljava/lang/Object;)V +} + public abstract class com/juul/krayon/element/Element { public static final field Companion Lcom/juul/krayon/element/Element$Companion; public fun ()V @@ -71,10 +73,15 @@ public final class com/juul/krayon/element/GroupElement$Companion : com/juul/kra public fun trySelect (Lcom/juul/krayon/element/Element;)Lcom/juul/krayon/element/GroupElement; } -public abstract interface class com/juul/krayon/element/Interactable { +public abstract interface class com/juul/krayon/element/HoverHandler { + public abstract fun onHoverChanged (Ljava/lang/Object;Z)V +} + +public abstract class com/juul/krayon/element/InteractableElement : com/juul/krayon/element/Element { + public fun ()V public abstract fun getInteractionPath ()Lcom/juul/krayon/kanvas/Path; - public abstract fun getOnClick ()Lkotlin/jvm/functions/Function1; - public abstract fun setOnClick (Lkotlin/jvm/functions/Function1;)V + public final fun onClick (Lcom/juul/krayon/element/ClickHandler;)V + public final fun onHoverChanged (Lcom/juul/krayon/element/HoverHandler;)V } public final class com/juul/krayon/element/KanvasElement : com/juul/krayon/element/Element { @@ -121,16 +128,14 @@ public final class com/juul/krayon/element/LineElement$Companion : com/juul/kray public fun trySelect (Lcom/juul/krayon/element/Element;)Lcom/juul/krayon/element/LineElement; } -public final class com/juul/krayon/element/PathElement : com/juul/krayon/element/Element, com/juul/krayon/element/Interactable { +public final class com/juul/krayon/element/PathElement : com/juul/krayon/element/InteractableElement { public static final field Companion Lcom/juul/krayon/element/PathElement$Companion; public fun ()V public fun draw (Lcom/juul/krayon/kanvas/Kanvas;)V public fun getInteractionPath ()Lcom/juul/krayon/kanvas/Path; - public fun getOnClick ()Lkotlin/jvm/functions/Function1; public final fun getPaint ()Lcom/juul/krayon/kanvas/Paint; public final fun getPath ()Lcom/juul/krayon/kanvas/Path; public fun getTag ()Ljava/lang/String; - public fun setOnClick (Lkotlin/jvm/functions/Function1;)V public final fun setPaint (Lcom/juul/krayon/kanvas/Paint;)V public final fun setPath (Lcom/juul/krayon/kanvas/Path;)V } @@ -142,21 +147,19 @@ public final class com/juul/krayon/element/PathElement$Companion : com/juul/kray public fun trySelect (Lcom/juul/krayon/element/Element;)Lcom/juul/krayon/element/PathElement; } -public final class com/juul/krayon/element/RectangleElement : com/juul/krayon/element/Element, com/juul/krayon/element/Interactable { +public final class com/juul/krayon/element/RectangleElement : com/juul/krayon/element/InteractableElement { public static final field Companion Lcom/juul/krayon/element/RectangleElement$Companion; public fun ()V public fun draw (Lcom/juul/krayon/kanvas/Kanvas;)V public final fun getBottom ()F public fun getInteractionPath ()Lcom/juul/krayon/kanvas/Path; public final fun getLeft ()F - public fun getOnClick ()Lkotlin/jvm/functions/Function1; public final fun getPaint ()Lcom/juul/krayon/kanvas/Paint; public final fun getRight ()F public fun getTag ()Ljava/lang/String; public final fun getTop ()F public final fun setBottom (F)V public final fun setLeft (F)V - public fun setOnClick (Lkotlin/jvm/functions/Function1;)V public final fun setPaint (Lcom/juul/krayon/kanvas/Paint;)V public final fun setRight (F)V public final fun setTop (F)V @@ -175,7 +178,9 @@ public final class com/juul/krayon/element/RootElement : com/juul/krayon/element public fun draw (Lcom/juul/krayon/kanvas/Kanvas;)V public final fun getOnClickFallback ()Lkotlin/jvm/functions/Function0; public fun getTag ()Ljava/lang/String; - public final fun onClick (Lcom/juul/krayon/kanvas/IsPointInPath;FF)Z + public final fun onClick (Lcom/juul/krayon/kanvas/IsPointInPath;FF)V + public final fun onHover (Lcom/juul/krayon/kanvas/IsPointInPath;FF)V + public final fun onHoverEnded ()V public final fun setOnClickFallback (Lkotlin/jvm/functions/Function0;)V } @@ -184,7 +189,7 @@ public final class com/juul/krayon/element/RootElement$Companion : com/juul/kray public fun trySelect (Lcom/juul/krayon/element/Element;)Lcom/juul/krayon/element/RootElement; } -public final class com/juul/krayon/element/RoundedRectangleElement : com/juul/krayon/element/Element, com/juul/krayon/element/Interactable { +public final class com/juul/krayon/element/RoundedRectangleElement : com/juul/krayon/element/InteractableElement { public static final field Companion Lcom/juul/krayon/element/RoundedRectangleElement$Companion; public fun ()V public fun draw (Lcom/juul/krayon/kanvas/Kanvas;)V @@ -193,7 +198,6 @@ public final class com/juul/krayon/element/RoundedRectangleElement : com/juul/kr public final fun getBottomRightRadius ()F public fun getInteractionPath ()Lcom/juul/krayon/kanvas/Path; public final fun getLeft ()F - public fun getOnClick ()Lkotlin/jvm/functions/Function1; public final fun getPaint ()Lcom/juul/krayon/kanvas/Paint; public final fun getRight ()F public fun getTag ()Ljava/lang/String; @@ -204,7 +208,6 @@ public final class com/juul/krayon/element/RoundedRectangleElement : com/juul/kr public final fun setBottomLeftRadius (F)V public final fun setBottomRightRadius (F)V public final fun setLeft (F)V - public fun setOnClick (Lkotlin/jvm/functions/Function1;)V public final fun setPaint (Lcom/juul/krayon/kanvas/Paint;)V public final fun setRight (F)V public final fun setTop (F)V diff --git a/element/src/commonMain/kotlin/CircleElement.kt b/element/src/commonMain/kotlin/CircleElement.kt index ce5101ad..153bbcfd 100644 --- a/element/src/commonMain/kotlin/CircleElement.kt +++ b/element/src/commonMain/kotlin/CircleElement.kt @@ -4,7 +4,7 @@ import com.juul.krayon.kanvas.Kanvas import com.juul.krayon.kanvas.Paint import com.juul.krayon.kanvas.Path -public class CircleElement : Element(), Interactable { +public class CircleElement : InteractableElement() { override val tag: String get() = "circle" @@ -12,7 +12,6 @@ public class CircleElement : Element(), Interactable { public var centerY: Float by attributes.withDefault { 0f } public var radius: Float by attributes.withDefault { 0f } public var paint: Paint by attributes.withDefault { DEFAULT_FILL } - override var onClick: ((CircleElement) -> Unit)? by attributes.withDefault { null } override fun draw(kanvas: Kanvas) { kanvas.drawCircle(centerX, centerY, radius, paint) diff --git a/element/src/commonMain/kotlin/Interactable.kt b/element/src/commonMain/kotlin/Interactable.kt deleted file mode 100644 index 29023e9a..00000000 --- a/element/src/commonMain/kotlin/Interactable.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.juul.krayon.element - -import com.juul.krayon.kanvas.Path - -public interface Interactable> { - public var onClick: ((T) -> Unit)? - public fun getInteractionPath(): Path -} diff --git a/element/src/commonMain/kotlin/InteractableElement.kt b/element/src/commonMain/kotlin/InteractableElement.kt new file mode 100644 index 00000000..fb296b96 --- /dev/null +++ b/element/src/commonMain/kotlin/InteractableElement.kt @@ -0,0 +1,59 @@ +package com.juul.krayon.element + +import com.juul.krayon.kanvas.Path + +/** + * Subclasses of this are able to receive click and hover events inside of an `ElementView`. These + * subclasses must implement [getInteractionPath] and the individual instances must have a non-null + * [onClick] or [onHoverChanged] handler set. These events are dispatched to the "top"-most element + * in the tree that would be able to fire, which occludes other elements below it. + * + * _Only_ elements with a set handler occlude other elements (so it may sometimes be useful to set a + * handler that no-ops to mask off another element's touch). Additionally, click and hover occlusion + * are handled separately, so an element with a click handler but not a hover handler will not block + * hover events below it. + * + * Note that the term "hover" is often different between platforms. The built-in `ElementView`s try + * to bridge this gap, and include both _true_ hover (a mouse pointer over the element, un-pressed) + * as well as the _press_ or _drag_ state (a mouse pointer on the element, pressed; or, a touch) as + * hover states. This enables use of hover as a pre-selection indicator on mobile devices. + */ +public abstract class InteractableElement> : Element() { + + internal var clickHandler: ClickHandler? by attributes.withDefault { null } + private set + + internal var hoverHandler: HoverHandler? by attributes.withDefault { null } + private set + + /** Set the [ClickHandler] for this element. */ + public fun onClick(handler: ClickHandler?) { + clickHandler = handler + } + + /** Set the [HoverHandler] for this element. */ + public fun onHoverChanged(handler: HoverHandler?) { + hoverHandler = handler + } + + /** The path used for hit detection. */ + public abstract fun getInteractionPath(): Path +} + +/** Handler for click events. */ +public fun interface ClickHandler { + /** + * Called when [element] is clicked. This event is usually dispatched on _release_ of a mouse + * press or touch. + */ + public fun onClick(element: T) +} + +/** Handler for hover events. */ +public fun interface HoverHandler { + /** + * Called when [element] starts to be hovered or stops being hovered. The [hovered] value + * represents the _new_ state (`true` when it starts to be hovered, `false` when it stops). + */ + public fun onHoverChanged(element: T, hovered: Boolean) +} diff --git a/element/src/commonMain/kotlin/PathElement.kt b/element/src/commonMain/kotlin/PathElement.kt index 161a9830..408829ed 100644 --- a/element/src/commonMain/kotlin/PathElement.kt +++ b/element/src/commonMain/kotlin/PathElement.kt @@ -4,13 +4,12 @@ import com.juul.krayon.kanvas.Kanvas import com.juul.krayon.kanvas.Paint import com.juul.krayon.kanvas.Path -public class PathElement : Element(), Interactable { +public class PathElement : InteractableElement() { override val tag: String get() = "path" public var path: Path by attributes.withDefault { 0f } public var paint: Paint by attributes.withDefault { DEFAULT_STROKE } - override var onClick: ((PathElement) -> Unit)? by attributes.withDefault { null } override fun draw(kanvas: Kanvas) { kanvas.drawPath(path, paint) diff --git a/element/src/commonMain/kotlin/RectangleElement.kt b/element/src/commonMain/kotlin/RectangleElement.kt index 3c6878bb..a0fa2d5b 100644 --- a/element/src/commonMain/kotlin/RectangleElement.kt +++ b/element/src/commonMain/kotlin/RectangleElement.kt @@ -4,7 +4,7 @@ import com.juul.krayon.kanvas.Kanvas import com.juul.krayon.kanvas.Paint import com.juul.krayon.kanvas.Path -public class RectangleElement : Element(), Interactable { +public class RectangleElement : InteractableElement() { override val tag: String get() = "rectangle" @@ -13,7 +13,6 @@ public class RectangleElement : Element(), Interactable { public var right: Float by attributes.withDefault { 0f } public var bottom: Float by attributes.withDefault { 0f } public var paint: Paint by attributes.withDefault { DEFAULT_FILL } - override var onClick: ((RectangleElement) -> Unit)? by attributes.withDefault { null } override fun draw(kanvas: Kanvas) { kanvas.drawRect(left, top, right, bottom, paint) diff --git a/element/src/commonMain/kotlin/RootElement.kt b/element/src/commonMain/kotlin/RootElement.kt index cf4946ab..24aa903f 100644 --- a/element/src/commonMain/kotlin/RootElement.kt +++ b/element/src/commonMain/kotlin/RootElement.kt @@ -1,5 +1,7 @@ package com.juul.krayon.element +import com.juul.krayon.element.InteractableType.Click +import com.juul.krayon.element.InteractableType.Hover import com.juul.krayon.kanvas.IsPointInPath import com.juul.krayon.kanvas.Kanvas import com.juul.krayon.kanvas.Transform @@ -9,6 +11,13 @@ public class RootElement : Element() { override val tag: String get() = "root" + /** + * The currently hovered value, recorded as an implementation detail of the hover-off event. + * + * This is NOT implemented as `by attribute` to avoid leaking its visibility. + */ + private var hoveredElement: InteractableElement<*>? = null + /** * If set, this callback is invoked when [onClick] is called but no descendant element handles * that event. @@ -22,31 +31,54 @@ public class RootElement : Element() { children.forEach { it.draw(kanvas) } } - /** Returns true if an element was found and clicked on. Returns false if no element matched. */ - public fun onClick(isPointInPath: IsPointInPath, x: Float, y: Float): Boolean { - // Union types would be pretty nice here. Value is of type T where T: Element and T: Interactable - val interactable = visibilityOrderedDescendants() - .filterIsInstance>() - .filter { it.onClick != null } - .firstOrNull { interactable -> - val transform = (interactable as Element).totalTransform() - isPointInPath.isPointInPath(transform, interactable.getInteractionPath(), x, y) - } - val fallback = onClickFallback - return when { - interactable != null -> { - @Suppress("UNCHECKED_CAST") // Interactable always accepts itself as the type argument. - val onClick = interactable.onClick as (Element) -> Unit - onClick(interactable as Element) - true - } - fallback != null -> { - fallback() - true + /** + * Entry point for dispatching hover events, both start and move. Usually you won't need to call + * this, and it will be handled by your platform-specific ElementView. + */ + public fun onHover(isPointInPath: IsPointInPath, x: Float, y: Float) { + val previousHoveredElement = hoveredElement + val newHoveredElement = interactableAtPoint(isPointInPath, x, y, type = Hover) + if (newHoveredElement != previousHoveredElement) { + if (previousHoveredElement != null) { + @Suppress("UNCHECKED_CAST") // Interactables always accepts themselves as the type argument. + val handler = previousHoveredElement.hoverHandler as HoverHandler? + handler?.onHoverChanged(previousHoveredElement, hovered = false) } - else -> { - false + if (newHoveredElement != null) { + @Suppress("UNCHECKED_CAST") // Interactables always accepts themselves as the type argument. + val handler = newHoveredElement.hoverHandler as HoverHandler + handler.onHoverChanged(newHoveredElement, hovered = true) } + hoveredElement = newHoveredElement + } + } + + /** + * Entry point for dispatching hover-end events. Usually you won't need to call this, and it will be + * handled by your platform-specific ElementView. + */ + public fun onHoverEnded() { + val element = hoveredElement ?: return + + @Suppress("UNCHECKED_CAST") // Interactables always accepts themselves as the type argument. + val handler = element.hoverHandler as HoverHandler? ?: return + handler.onHoverChanged(element, hovered = false) + hoveredElement = null + } + + /** + * Entry point for dispatching click events. Usually you won't need to call this, and it will be + * handled by your platform-specific ElementView. + */ + public fun onClick(isPointInPath: IsPointInPath, x: Float, y: Float) { + val clickedElement = interactableAtPoint(isPointInPath, x, y, type = Click) + val fallback = onClickFallback + if (clickedElement != null) { + @Suppress("UNCHECKED_CAST") // Interactables always accepts themselves as the type argument. + val handler = clickedElement.clickHandler as ClickHandler + handler.onClick(clickedElement) + } else if (fallback != null) { + fallback() } } @@ -55,6 +87,21 @@ public class RootElement : Element() { } } +/** Type-safe argument for [interactableAtPoint]. */ +private enum class InteractableType { Click, Hover } + +/** + * Returns all descendants of this [Element], sorted by visibility. This means that later elements + * always come before earlier elements, and children always come before their parent. + */ +private fun Element.visibilityOrderedDescendants(): Sequence = sequence { + for (child in children.asReversed()) { + yieldAll(child.visibilityOrderedDescendants()) + } + yield(this@visibilityOrderedDescendants) +} + +/** Returns the total transform that will affect how this element draws. */ private fun Element.totalTransform(): Transform { var element: Element? = this var transform: Transform = Translate() // Start with a no-op/identity transform @@ -67,9 +114,23 @@ private fun Element.totalTransform(): Transform { return transform } -private fun Element.visibilityOrderedDescendants(): Sequence = sequence { - for (child in children.asReversed()) { - yieldAll(child.visibilityOrderedDescendants()) +/** + * Finds the element that consumes/receives interaction. Depending on [type], this will only include + * elements that have the appropriate handler installed. + */ +private fun Element.interactableAtPoint( + isPointInPath: IsPointInPath, + x: Float, + y: Float, + type: InteractableType, +): InteractableElement<*>? = visibilityOrderedDescendants() + .filterIsInstance>() + .filter { element -> + when (type) { + Click -> element.clickHandler != null + Hover -> element.hoverHandler != null + } + }.firstOrNull { interactable -> + val transform = (interactable as Element).totalTransform() + isPointInPath.isPointInPath(transform, interactable.getInteractionPath(), x, y) } - yield(this@visibilityOrderedDescendants) -} diff --git a/element/src/commonMain/kotlin/RoundedRectangleElement.kt b/element/src/commonMain/kotlin/RoundedRectangleElement.kt index e1840bb2..3168d7a6 100644 --- a/element/src/commonMain/kotlin/RoundedRectangleElement.kt +++ b/element/src/commonMain/kotlin/RoundedRectangleElement.kt @@ -4,7 +4,7 @@ import com.juul.krayon.kanvas.Kanvas import com.juul.krayon.kanvas.Paint import com.juul.krayon.kanvas.Path -public class RoundedRectangleElement : Element(), Interactable { +public class RoundedRectangleElement : InteractableElement() { override val tag: String get() = "rounded-rectangle" @@ -20,8 +20,6 @@ public class RoundedRectangleElement : Element(), Interactable Unit)? by attributes.withDefault { null } - // TODO: Cache the generated path, lazily generating it only when it changes. override fun draw(kanvas: Kanvas) { diff --git a/sample/src/commonMain/kotlin/InteractiveTreeChart.kt b/sample/src/commonMain/kotlin/InteractiveTreeChart.kt index 7b7594c1..d0c0a8c7 100644 --- a/sample/src/commonMain/kotlin/InteractiveTreeChart.kt +++ b/sample/src/commonMain/kotlin/InteractiveTreeChart.kt @@ -7,6 +7,8 @@ import com.juul.krayon.color.darkBlue import com.juul.krayon.color.forestGreen import com.juul.krayon.color.lerp import com.juul.krayon.color.white +import com.juul.krayon.element.ClickHandler +import com.juul.krayon.element.HoverHandler import com.juul.krayon.element.RectangleElement import com.juul.krayon.element.RootElement import com.juul.krayon.element.TextElement @@ -32,7 +34,7 @@ import com.juul.krayon.selection.join import com.juul.krayon.selection.selectAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlin.random.Random enum class Letter { @@ -50,17 +52,32 @@ enum class Letter { data class InteractiveTreeChart( val selection: Letter?, + val hovered: Letter?, val counts: Map, ) internal fun interactiveTreeChart(): Pair, UpdateElement> { - val selection = MutableStateFlow(null as Letter?) + val selectionState = MutableStateFlow(null as Letter?) + val hoveredState = MutableStateFlow(null as Letter?) val letterSizes = Letter.values().associateWith { Random.nextInt(50, 500) } - val dataFlow = selection.map { InteractiveTreeChart(it, letterSizes) } + val dataFlow = combine(selectionState, hoveredState) { selection, hovered -> + InteractiveTreeChart(selection, hovered, letterSizes) + } val updater = UpdateElement { root, width, height, data -> - updateInteractiveTreeChart(root, width, height, data) { letter -> - selection.value = letter.takeUnless { it == selection.value } - } + updateInteractiveTreeChart( + root, + width, + height, + data, + clickHandler = { letter -> selectionState.value = letter.takeUnless { it == selectionState.value } }, + hoverHandler = { letter, hovered -> + if (!hovered && hoveredState.value == letter) { + hoveredState.value = null + } else if (hovered) { + hoveredState.value = letter + } + }, + ) } return dataFlow to updater } @@ -70,17 +87,23 @@ private fun updateInteractiveTreeChart( width: Float, height: Float, data: InteractiveTreeChart, - sideEffect: (Letter) -> Unit, + clickHandler: ClickHandler, + hoverHandler: HoverHandler, ) { val min = data.counts.values.min { it } val max = data.counts.values.max { it } fun colorFor(letter: Letter): Color { val value = data.counts[letter] ?: 0 - return if (letter == data.selection) { + val baseColor = if (letter == data.selection) { forestGreen } else { lerp(crimson, darkBlue, (value - min).toFloat() / (max - min)) } + return if (letter == data.hovered) { + lerp(baseColor, white, 0.25f) + } else { + baseColor + } } val lettersToTiles = flatHierarchy(data.counts.entries) @@ -100,7 +123,8 @@ private fun updateInteractiveTreeChart( Fill(colorFor(entry.key)), Paint.Stroke(white, 2f), ) - onClick = { sideEffect(entry.key) } + onClick { clickHandler.onClick(entry.key) } + onHoverChanged { _, hovered -> hoverHandler.onHoverChanged(entry.key, hovered) } } val textPaint = Text(white, size = 12f, alignment = Center, Font(sansSerif))