Skip to content

Commit c4bb60e

Browse files
committed
hoveredState: Rewrite to be much more efficient
Instead of doing a hitTest for each hoverable component, we now do a single one per Window and distribut the result. This also removes the `hitTest` argument because it is now an inherent part of how it functions and we never disabled it anyway (and there isn't really any reason you'd ever want to). This also removes the `layoutSafe` argument because the the new `UpdateFunc` API this uses is layout safe by default, with no extra frame delay necessary. Source-Commit: 7e67d6c184370ac5633b44c74a893daba2031d32
1 parent c98e651 commit c4bb60e

File tree

2 files changed

+31
-78
lines changed

2 files changed

+31
-78
lines changed

unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import gg.essential.elementa.state.v2.collections.TrackedList
1212
import gg.essential.elementa.state.v2.collections.trackedListOf
1313
import gg.essential.elementa.state.v2.combinators.map
1414
import gg.essential.elementa.state.v2.combinators.not
15-
import gg.essential.elementa.util.hoveredState
1615
import kotlin.contracts.ExperimentalContracts
1716
import kotlin.contracts.InvocationKind
1817
import kotlin.contracts.contract
@@ -49,9 +48,6 @@ class LayoutScope(
4948

5049
operator fun LayoutDslComponent.invoke(modifier: Modifier = Modifier) = layout(modifier)
5150

52-
@Deprecated("Use Modifier.hoverScope() and Modifier.whenHovered(), instead.")
53-
fun hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true) = component.hoveredState(hitTest, layoutSafe)
54-
5551
@Suppress("FunctionName")
5652
fun if_(state: State<Boolean>, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl {
5753
return if_(state.toV2(), cache, block)

unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt

Lines changed: 31 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import gg.essential.elementa.state.v2.State as StateV2
2020
val UIComponent.hasWindow: Boolean
2121
get() = this is Window || hasParent && parent.hasWindow
2222

23+
inline fun <reified T : Effect> UIComponent.get() =
24+
effects.firstNotNullOfOrNull { it as? T }
25+
inline fun <reified T : Effect> UIComponent.getOrPut(init: () -> T) =
26+
get<T>() ?: init().also { enableEffect(it) }
27+
2328
fun <T> UIComponent.pollingState(initialValue: T? = null, getter: () -> T): State<T> {
2429
val state = BasicState(initialValue ?: getter())
2530
enableEffect(object : Effect() {
@@ -122,98 +127,50 @@ fun UIComponent.onAnimationFrame(block: () -> Unit) =
122127

123128
/**
124129
* Returns a state representing whether this UIComponent is hovered
125-
*
126-
* [hitTest] will perform a hit test to make sure the user is actually hovered over this component
127-
* as compared to the mouse just being within its content bounds while being hovered over another
128-
* component rendered above this.
129-
*
130-
* [layoutSafe] will delay the state change until a time in which it is safe to make layout changes.
131-
* This option will induce an additional delay of one frame because the state is updated during the next
132-
* [Window.enqueueRenderOperation] after the hoverState changes.
133130
*/
134-
fun UIComponent.hoveredStateV2(hitTest: Boolean = true, layoutSafe: Boolean = true): StateV2<Boolean> {
135-
// "Unsafe" means that it is not safe to depend on this for layout changes
136-
val unsafeHovered = mutableStateOf(false)
131+
fun UIComponent.hoveredStateV2(): StateV2<Boolean> {
132+
return getOrPut { ComponentHoveredState() }.state
133+
}
134+
135+
private class WindowHoveredState : Effect() {
136+
private var oldHovered = listOf<UIComponent>()
137137

138-
// "Safe" because layout changes can directly happen when this changes (ie in onSetValue)
139-
val safeHovered = mutableStateOf(false)
138+
init { addUpdateFunc { _, _ -> update() } }
139+
private fun update() {
140+
val window = boundComponent as Window
140141

141-
// Performs a hit test based on the current mouse x / y
142-
fun hitTestHovered(): Boolean {
143142
// Positions the mouse in the center of pixels so isPointInside will
144143
// pass for items 1 pixel wide objects. See ElementaVersion v2 for more details
145144
val halfPixel = 0.5f / UResolution.scaleFactor.toFloat()
146145
val mouseX = UMouse.Scaled.x.toFloat() + halfPixel
147146
val mouseY = UMouse.Scaled.y.toFloat() + halfPixel
148-
return if (isPointInside(mouseX, mouseY)) {
149147

150-
val window = Window.of(this)
151-
val hit = (window.hoveredFloatingComponent?.hitTest(mouseX, mouseY)) ?: window.hitTest(mouseX, mouseY)
152-
153-
hit.isComponentInParentChain(this) || hit == this
154-
} else {
155-
false
156-
}
157-
}
158-
159-
if (hitTest) {
160-
// It's possible the animation framerate will exceed that of the actual frame rate
161-
// Therefore, in order to avoid redundantly performing the hit test multiple times
162-
// in the same frame, this boolean is used to ensure that hit testing is performed
163-
// at most only a single time each frame
164-
var registerHitTest = true
165-
166-
onAnimationFrame {
167-
if (registerHitTest) {
168-
registerHitTest = false
169-
Window.enqueueRenderOperation {
170-
// The next animation frame should register another renderOperation
171-
registerHitTest = true
172-
173-
// It is possible that this component or a component in its parent tree
174-
// was removed from the component tree between the last call to animationFrame
175-
// and this evaluation in enqueueRenderOperation. If that is the case, we should not
176-
// perform the hit test because it will throw an exception.
177-
if (!this.isInComponentTree()) {
178-
// Unset the hovered state because a component can no longer
179-
// be hovered if it is not in the component tree
180-
unsafeHovered.set(false)
181-
return@enqueueRenderOperation
182-
}
183-
184-
// Since enqueueRenderOperation will keep polling the queue until there are no more items,
185-
// the forwarding of any update to the safeHovered state will still happen this frame
186-
unsafeHovered.set(hitTestHovered())
187-
}
148+
val hit = (window.hoveredFloatingComponent?.hitTest(mouseX, mouseY)) ?: window.hitTest(mouseX, mouseY)
149+
val newHovered = hit.selfAndParents().toList()
150+
for (component in oldHovered) {
151+
if (component !in newHovered) {
152+
component.get<ComponentHoveredState>()?.state?.set(false)
188153
}
189154
}
190-
}
191-
onMouseEnter {
192-
if (hitTest) {
193-
unsafeHovered.set(hitTestHovered())
194-
} else {
195-
unsafeHovered.set(true)
155+
for (component in newHovered) {
156+
if (component !in oldHovered) {
157+
component.get<ComponentHoveredState>()?.state?.set(true)
158+
}
196159
}
160+
oldHovered = newHovered
197161
}
162+
}
198163

199-
onMouseLeave {
200-
unsafeHovered.set(false)
201-
}
164+
private class ComponentHoveredState : Effect() {
165+
val state = mutableStateOf(false)
202166

203-
return if (layoutSafe) {
204-
unsafeHovered.onChange(this) { hovered ->
205-
Window.enqueueRenderOperation {
206-
safeHovered.set(hovered)
207-
}
208-
}
209-
safeHovered
210-
} else {
211-
unsafeHovered
167+
override fun setup() {
168+
Window.of(boundComponent).getOrPut { WindowHoveredState() }
212169
}
213170
}
214171

215-
fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true): State<Boolean> =
216-
hoveredStateV2(hitTest, layoutSafe).toV1(this)
172+
fun UIComponent.hoveredState(): State<Boolean> =
173+
hoveredStateV2().toV1(this)
217174

218175
/** Marker effect for [makeHoverScope]/[hoverScope]. */
219176
private class HoverScope(val state: StateV2<Boolean>) : Effect()

0 commit comments

Comments
 (0)