1 /*
2 * Copyright 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.input.pointer
18
19 import androidx.compose.runtime.Stable
20 import androidx.compose.ui.Modifier
21 import androidx.compose.ui.input.pointer.PointerEventPass.Main
22 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
23 import androidx.compose.ui.node.DpTouchBoundsExpansion
24 import androidx.compose.ui.node.ModifierNodeElement
25 import androidx.compose.ui.node.PointerInputModifierNode
26 import androidx.compose.ui.node.TouchBoundsExpansion
27 import androidx.compose.ui.node.TraversableNode
28 import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction
29 import androidx.compose.ui.node.currentValueOf
30 import androidx.compose.ui.node.requireDensity
31 import androidx.compose.ui.node.traverseAncestors
32 import androidx.compose.ui.node.traverseDescendants
33 import androidx.compose.ui.platform.InspectorInfo
34 import androidx.compose.ui.platform.LocalPointerIconService
35 import androidx.compose.ui.unit.IntSize
36 import androidx.compose.ui.util.fastAny
37
38 /**
39 * Represents a pointer icon to use in [Modifier.pointerHoverIcon] or [Modifier.stylusHoverIcon].
40 */
41 @Stable
42 interface PointerIcon {
43
44 /**
45 * A collection of common pointer icons used for the mouse cursor. These icons will be used to
46 * assign default pointer icons for various widgets.
47 */
48 companion object {
49
50 /** The default arrow icon that is commonly used for cursor icons. */
51 val Default = pointerIconDefault
52
53 /** Commonly used when selecting precise portions of the screen. */
54 val Crosshair = pointerIconCrosshair
55
56 /** Also called an I-beam cursor, this is commonly used on selectable or editable text. */
57 val Text = pointerIconText
58
59 /** Commonly used to indicate to a user that an element is clickable. */
60 val Hand = pointerIconHand
61 }
62 }
63
64 internal expect val pointerIconDefault: PointerIcon
65 internal expect val pointerIconCrosshair: PointerIcon
66 internal expect val pointerIconText: PointerIcon
67 internal expect val pointerIconHand: PointerIcon
68
69 internal interface PointerIconService {
getIconnull70 fun getIcon(): PointerIcon
71
72 fun setIcon(value: PointerIcon?)
73
74 fun getStylusHoverIcon(): PointerIcon?
75
76 fun setStylusHoverIcon(value: PointerIcon?)
77 }
78
79 /**
80 * Modifier that lets a developer define a pointer icon to display when the cursor is hovered over
81 * the element. When [overrideDescendants] is set to true, descendants cannot override the pointer
82 * icon using this modifier.
83 *
84 * @sample androidx.compose.ui.samples.PointerIconSample
85 * @param icon the icon to set
86 * @param overrideDescendants when false (by default), descendants are able to set their own pointer
87 * icon. If true, no descendants under this parent are eligible to change the icon (it will be set
88 * to the this (the parent's) icon).
89 */
90 @Stable
91 fun Modifier.pointerHoverIcon(icon: PointerIcon, overrideDescendants: Boolean = false) =
92 this then
93 PointerHoverIconModifierElement(icon = icon, overrideDescendants = overrideDescendants)
94
95 internal data class PointerHoverIconModifierElement(
96 val icon: PointerIcon,
97 val overrideDescendants: Boolean = false
98 ) : ModifierNodeElement<PointerHoverIconModifierNode>() {
99 override fun create() = PointerHoverIconModifierNode(icon, overrideDescendants)
100
101 override fun update(node: PointerHoverIconModifierNode) {
102 node.icon = icon
103 node.overrideDescendants = overrideDescendants
104 }
105
106 override fun InspectorInfo.inspectableProperties() {
107 name = "pointerHoverIcon"
108 properties["icon"] = icon
109 properties["overrideDescendants"] = overrideDescendants
110 }
111 }
112
113 /*
114 * Changes the pointer hover icon if the node is in bounds and if the node is not overridden
115 * by a parent pointer hover icon node. This node implements [PointerInputModifierNode] so it can
116 * listen to pointer input events and determine if the pointer has entered or exited the bounds of
117 * the modifier itself.
118 *
119 * If the icon or overrideDescendants values are changed, this node will determine if it needs to
120 * walk down and/or up the modifier chain to update those pointer hover icon modifier nodes as well.
121 */
122 internal class PointerHoverIconModifierNode(
123 icon: PointerIcon,
124 overrideDescendants: Boolean = false
125 ) : HoverIconModifierNode(icon, overrideDescendants) {
126 /* Traversal key used with the [TraversableNode] interface to enable all the traversing
127 * functions (ancestor, child, subtree, and subtreeIf).
128 */
129 override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon"
130
isRelevantPointerTypenull131 override fun isRelevantPointerType(pointerType: PointerType) =
132 pointerType != PointerType.Stylus && pointerType != PointerType.Eraser
133
134 override fun displayIcon(icon: PointerIcon?) {
135 pointerIconService?.setIcon(icon)
136 }
137 }
138
139 /**
140 * Modifier that lets a developer define a pointer icon to display when a stylus is hovered over the
141 * element. When [overrideDescendants] is set to true, descendants cannot override the pointer icon
142 * using this modifier.
143 *
144 * @param icon the icon to set
145 * @param overrideDescendants when false (by default), descendants are able to set their own pointer
146 * icon. If true, no descendants under this parent are eligible to change the icon (it will be set
147 * to the this (the parent's) icon).
148 * @param touchBoundsExpansion amount by which the element's bounds is expanded
149 * @sample androidx.compose.ui.samples.StylusHoverIconSample
150 */
stylusHoverIconnull151 fun Modifier.stylusHoverIcon(
152 icon: PointerIcon,
153 overrideDescendants: Boolean = false,
154 touchBoundsExpansion: DpTouchBoundsExpansion? = null
155 ) =
156 this then
157 StylusHoverIconModifierElement(
158 icon = icon,
159 overrideDescendants = overrideDescendants,
160 touchBoundsExpansion = touchBoundsExpansion
161 )
162
163 internal data class StylusHoverIconModifierElement(
164 val icon: PointerIcon,
165 val overrideDescendants: Boolean = false,
166 val touchBoundsExpansion: DpTouchBoundsExpansion? = null
167 ) : ModifierNodeElement<StylusHoverIconModifierNode>() {
168 override fun create() =
169 StylusHoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion)
170
171 override fun update(node: StylusHoverIconModifierNode) {
172 node.icon = icon
173 node.overrideDescendants = overrideDescendants
174 node.dpTouchBoundsExpansion = touchBoundsExpansion
175 }
176
177 override fun InspectorInfo.inspectableProperties() {
178 name = "stylusHoverIcon"
179 properties["icon"] = icon
180 properties["overrideDescendants"] = overrideDescendants
181 properties["touchBoundsExpansion"] = touchBoundsExpansion
182 }
183 }
184
185 internal class StylusHoverIconModifierNode(
186 icon: PointerIcon,
187 overrideDescendants: Boolean = false,
188 touchBoundsExpansion: DpTouchBoundsExpansion? = null
189 ) : HoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion) {
190 /* Traversal key used with the [TraversableNode] interface to enable all the traversing
191 * functions (ancestor, child, subtree, and subtreeIf).
192 */
193 override val traverseKey = "androidx.compose.ui.input.pointer.StylusHoverIcon"
194
isRelevantPointerTypenull195 override fun isRelevantPointerType(pointerType: PointerType) =
196 pointerType == PointerType.Stylus || pointerType == PointerType.Eraser
197
198 override fun displayIcon(icon: PointerIcon?) {
199 pointerIconService?.setStylusHoverIcon(icon)
200 }
201 }
202
203 internal abstract class HoverIconModifierNode(
204 icon: PointerIcon,
205 overrideDescendants: Boolean = false,
206 var dpTouchBoundsExpansion: DpTouchBoundsExpansion? = null
207 ) :
208 Modifier.Node(),
209 TraversableNode,
210 PointerInputModifierNode,
211 CompositionLocalConsumerModifierNode {
212
213 var icon = icon
214 set(value) {
215 if (field != value) {
216 field = value
217 if (cursorInBoundsOfNode) {
218 displayIconIfDescendantsDoNotHavePriority()
219 }
220 }
221 }
222
223 var overrideDescendants = overrideDescendants
224 set(value) {
225 if (field != value) {
226 field = value
227
228 if (overrideDescendants) { // overrideDescendants changed from false -> true
229 // If this node or any descendants have the cursor in bounds, change the icon.
230 if (cursorInBoundsOfNode) {
231 displayIcon()
232 }
233 } else { // overrideDescendants changed from true -> false
234 if (cursorInBoundsOfNode) {
235 displayIconFromCurrentNodeOrDescendantsWithCursorInBounds()
236 }
237 }
238 }
239 }
240
241 // Service used to actually update the icon with the system when needed.
242 protected val pointerIconService: PointerIconService?
243 get() = currentValueOf(LocalPointerIconService)
244
245 private var cursorInBoundsOfNode = false
246
247 // Pointer Input callback for determining if a Pointer has Entered or Exited this node.
onPointerEventnull248 override fun onPointerEvent(
249 pointerEvent: PointerEvent,
250 pass: PointerEventPass,
251 bounds: IntSize
252 ) {
253 if (pass == Main && pointerEvent.changes.fastAny { isRelevantPointerType(it.type) }) {
254 // Cursor within the surface area of this node's bounds
255 if (pointerEvent.type == PointerEventType.Enter) {
256 onEnter()
257 } else if (pointerEvent.type == PointerEventType.Exit) {
258 onExit()
259 }
260 }
261 }
262
onEnternull263 private fun onEnter() {
264 cursorInBoundsOfNode = true
265 displayIconIfDescendantsDoNotHavePriority()
266 }
267
onExitnull268 private fun onExit() {
269 if (cursorInBoundsOfNode) {
270 cursorInBoundsOfNode = false
271
272 if (isAttached) {
273 displayIconFromAncestorNodeWithCursorInBoundsOrDefaultIcon()
274 }
275 }
276 }
277
onCancelPointerInputnull278 override fun onCancelPointerInput() {
279 // While pointer icon only really cares about enter/exit, there are some cases (dynamically
280 // adding Modifier Nodes) where a modifier might be cancelled but hasn't been detached or
281 // exited, so we need to cover that case.
282 onExit()
283 }
284
onDetachnull285 override fun onDetach() {
286 onExit()
287 super.onDetach()
288 }
289
290 override val touchBoundsExpansion: TouchBoundsExpansion
291 get() =
292 dpTouchBoundsExpansion?.roundToTouchBoundsExpansion(requireDensity())
293 ?: TouchBoundsExpansion.None
294
isRelevantPointerTypenull295 abstract fun isRelevantPointerType(pointerType: PointerType): Boolean
296
297 abstract fun displayIcon(icon: PointerIcon?)
298
299 private fun displayIcon() {
300 // If there are any ancestor that override this node, we must use that icon. Otherwise, we
301 // use the current node's icon
302 val iconToUse = findOverridingAncestorNode()?.icon ?: icon
303 displayIcon(iconToUse)
304 }
305
displayIconIfDescendantsDoNotHavePrioritynull306 private fun displayIconIfDescendantsDoNotHavePriority() {
307 var hasIconRightsOverDescendants = true
308
309 if (!overrideDescendants) {
310 traverseDescendants {
311 // Descendant in bounds has rights to the icon (and has already set it),
312 // so we ignore.
313 val continueTraversal =
314 if (it.cursorInBoundsOfNode) {
315 hasIconRightsOverDescendants = false
316 TraverseDescendantsAction.CancelTraversal
317 } else {
318 TraverseDescendantsAction.ContinueTraversal
319 }
320 continueTraversal
321 }
322 }
323
324 if (hasIconRightsOverDescendants) {
325 displayIcon()
326 }
327 }
328
329 /*
330 * Finds and returns the lowest descendant node with the cursor within its bounds (true node
331 * that gets to decide the icon).
332 *
333 * Note: Multiple descendant nodes may have `cursorInBoundsOfNode` set to true (for when the
334 * cursor enters their bounds). The lowest one is the one that is the correct node for the
335 * pointer (see example for explanation).
336 *
337 * Example: Parent node contains a child node within its visual border (both are pointer icon
338 * nodes).
339 * - Pointer moves over the PARENT node triggers the pointer input handler ENTER event which
340 * sets `cursorInBoundsOfNode` = `true`.
341 * - Pointer moves over CHILD node triggers the pointer input handler ENTER event which sets
342 * `cursorInBoundsOfNode` = `true`.
343 *
344 * They are both true now because the pointer input event's exit is not triggered (which would
345 * set cursorInBoundsOfNode` = `false`) unless the pointer moves outside the parent node.
346 * Because the child node is contained visually within the parent node, it is not triggered.
347 * That is why we need to get the lowest node with `cursorInBoundsOfNode` set to true.
348 */
findDescendantNodeWithCursorInBoundsnull349 private fun findDescendantNodeWithCursorInBounds(): HoverIconModifierNode? {
350 var descendantNodeWithCursorInBounds: HoverIconModifierNode? = null
351
352 traverseDescendants {
353 var actionForSubtreeOfCurrentNode = TraverseDescendantsAction.ContinueTraversal
354
355 if (it.cursorInBoundsOfNode) {
356 descendantNodeWithCursorInBounds = it
357
358 // No descendant nodes below this one are eligible to set the icon.
359 if (it.overrideDescendants) {
360 actionForSubtreeOfCurrentNode =
361 TraverseDescendantsAction.SkipSubtreeAndContinueTraversal
362 }
363 }
364 actionForSubtreeOfCurrentNode
365 }
366
367 return descendantNodeWithCursorInBounds
368 }
369
displayIconFromCurrentNodeOrDescendantsWithCursorInBoundsnull370 private fun displayIconFromCurrentNodeOrDescendantsWithCursorInBounds() {
371 if (!cursorInBoundsOfNode) return
372
373 var hoverIconModifierNode: HoverIconModifierNode = this
374
375 if (!overrideDescendants) {
376 findDescendantNodeWithCursorInBounds()?.let { hoverIconModifierNode = it }
377 }
378
379 hoverIconModifierNode.displayIcon()
380 }
381
findOverridingAncestorNodenull382 private fun findOverridingAncestorNode(): HoverIconModifierNode? {
383 var hoverIconModifierNode: HoverIconModifierNode? = null
384
385 traverseAncestors {
386 if (it.overrideDescendants && it.cursorInBoundsOfNode) {
387 hoverIconModifierNode = it
388 }
389 // continue traversal
390 true
391 }
392
393 return hoverIconModifierNode
394 }
395
396 /*
397 * Sets the icon to either the ancestor where the pointer is in its bounds (or to its
398 * ancestors if one overrides it) or to a default icon.
399 */
displayIconFromAncestorNodeWithCursorInBoundsOrDefaultIconnull400 private fun displayIconFromAncestorNodeWithCursorInBoundsOrDefaultIcon() {
401 var hoverIconModifierNode: HoverIconModifierNode? = null
402
403 traverseAncestors {
404 if (hoverIconModifierNode == null && it.cursorInBoundsOfNode) {
405 hoverIconModifierNode = it
406
407 // We should only assign a node that override its descendants if there was a node
408 // below it where the pointer was in bounds meaning the hoverIconModifierNode will
409 // not be null.
410 } else if (
411 hoverIconModifierNode != null && it.overrideDescendants && it.cursorInBoundsOfNode
412 ) {
413 hoverIconModifierNode = it
414 }
415
416 // continue traversal
417 true
418 }
419 hoverIconModifierNode?.displayIcon() ?: displayIcon(null)
420 }
421 }
422