Skip to content

Commit

Permalink
Hover Support for Interactable Elements (#168)
Browse files Browse the repository at this point in the history
  • Loading branch information
cedrickcooke authored Aug 9, 2022
1 parent 9722a09 commit e92338f
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 79 deletions.
54 changes: 52 additions & 2 deletions compose/src/commonMain/kotlin/ElementView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,15 +83,55 @@ public fun <T> 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
},
)
},
Expand Down
1 change: 1 addition & 0 deletions element-view/api/element-view.api
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ public final class com/juul/krayon/element/view/ElementView : android/view/View
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
public synthetic fun <init> (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
}
Expand Down
45 changes: 42 additions & 3 deletions element-view/src/androidMain/kotlin/ElementView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 16 additions & 4 deletions element-view/src/androidMain/kotlin/ElementViewAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,14 +47,27 @@ public class ElementViewAdapter<T>(
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. */
Expand Down
12 changes: 12 additions & 0 deletions element-view/src/jsMain/kotlin/ElementViewAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ public class ElementViewAdapter<T>(
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)
Expand Down
6 changes: 6 additions & 0 deletions element-view/src/jsMain/kotlin/HTMLCanvasElement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
35 changes: 19 additions & 16 deletions element/api/element.api
Original file line number Diff line number Diff line change
@@ -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 <init> ()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
}
Expand All @@ -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 <init> ()V
Expand Down Expand Up @@ -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 <init> ()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 {
Expand Down Expand Up @@ -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 <init> ()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
}
Expand All @@ -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 <init> ()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
Expand All @@ -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
}

Expand All @@ -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 <init> ()V
public fun draw (Lcom/juul/krayon/kanvas/Kanvas;)V
Expand All @@ -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;
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions element/src/commonMain/kotlin/CircleElement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import com.juul.krayon.kanvas.Kanvas
import com.juul.krayon.kanvas.Paint
import com.juul.krayon.kanvas.Path

public class CircleElement : Element(), Interactable<CircleElement> {
public class CircleElement : InteractableElement<CircleElement>() {

override val tag: String get() = "circle"

public var centerX: Float by attributes.withDefault { 0f }
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)
Expand Down
8 changes: 0 additions & 8 deletions element/src/commonMain/kotlin/Interactable.kt

This file was deleted.

Loading

0 comments on commit e92338f

Please sign in to comment.