All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.text.handwriting.StylusHandwriting.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

There is a newer version: 1.7.1
Show newest version
/*
 * Copyright 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.foundation.text.handwriting

import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusEventModifierNode
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.fastFirstOrNull

/**
 * A modifier that detects stylus movements and calls the [onHandwritingSlopExceeded] when
 * it detects that stylus movement has exceeds the handwriting slop.
 * If [onHandwritingSlopExceeded] returns true, it will consume the events and consider
 * that the handwriting has successfully started. Otherwise, it'll stop monitoring the current
 * gesture.
 * @param enabled whether this modifier is enabled, it's used for the case where the editor is
 * readOnly or disabled.
 * @param onHandwritingSlopExceeded the callback that's invoked when it detects stylus handwriting.
 * The return value determines whether the handwriting is triggered or not. When it's true, this
 * modifier will consume the pointer events.
 */
internal fun Modifier.stylusHandwriting(
    enabled: Boolean,
    onHandwritingSlopExceeded: () -> Boolean
): Modifier = if (enabled && isStylusHandwritingSupported) {
    this.then(StylusHandwritingElementWithNegativePadding(onHandwritingSlopExceeded))
        .padding(
            horizontal = HandwritingBoundsHorizontalOffset,
            vertical = HandwritingBoundsVerticalOffset
        )
} else {
    this
}

private data class StylusHandwritingElementWithNegativePadding(
    val onHandwritingSlopExceeded: () -> Boolean
) : ModifierNodeElement() {
    override fun create(): StylusHandwritingNodeWithNegativePadding {
        return StylusHandwritingNodeWithNegativePadding(onHandwritingSlopExceeded)
    }

    override fun update(node: StylusHandwritingNodeWithNegativePadding) {
        node.onHandwritingSlopExceeded = onHandwritingSlopExceeded
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "stylusHandwriting"
        properties["onHandwritingSlopExceeded"] = onHandwritingSlopExceeded
    }
}

/**
 * A stylus handwriting node with negative padding. This node should be  used in pair with a padding
 * modifier. Together, they expands the touch bounds of the editor while keep its visual bounds the
 * same.
 * Note: this node is a temporary solution, ideally we don't need it.
 */
internal class StylusHandwritingNodeWithNegativePadding(
    onHandwritingSlopExceeded: () -> Boolean
) : StylusHandwritingNode(onHandwritingSlopExceeded), LayoutModifierNode {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val paddingVerticalPx = HandwritingBoundsVerticalOffset.roundToPx()
        val paddingHorizontalPx = HandwritingBoundsHorizontalOffset.roundToPx()
        val newConstraint = constraints.offset(
            2 * paddingHorizontalPx,
            2 * paddingVerticalPx
        )
        val placeable = measurable.measure(newConstraint)

        val height = placeable.height - paddingVerticalPx * 2
        val width = placeable.width - paddingHorizontalPx * 2
        return layout(width, height) {
            placeable.place(-paddingHorizontalPx, -paddingVerticalPx)
        }
    }

    override fun sharePointerInputWithSiblings(): Boolean {
        // Share events to siblings so that the expanded touch bounds won't block other elements
        // surrounding the editor.
        return true
    }
}

internal open class StylusHandwritingNode(
    var onHandwritingSlopExceeded: () -> Boolean
) : DelegatingNode(), PointerInputModifierNode, FocusEventModifierNode {

    private var focused = false

    override fun onFocusEvent(focusState: FocusState) {
        focused = focusState.isFocused
    }

    private val suspendingPointerInputModifierNode = delegate(SuspendingPointerInputModifierNode {
        awaitEachGesture {
            val firstDown =
                awaitFirstDown(requireUnconsumed = true, pass = PointerEventPass.Initial)

            val isStylus =
                firstDown.type == PointerType.Stylus || firstDown.type == PointerType.Eraser
            if (!isStylus) {
                return@awaitEachGesture
            }

            val isInBounds = firstDown.position.x >= 0 && firstDown.position.x < size.width &&
                firstDown.position.y >= 0 && firstDown.position.y < size.height

            // If the editor is focused or the first down is within the editor's bounds, we
            // await the initial pass. This prioritize the focused editor over unfocused
            // editor.
            val pass = if (focused || isInBounds) {
                PointerEventPass.Initial
            } else {
                PointerEventPass.Main
            }

            // Await the touch slop before long press timeout.
            var exceedsTouchSlop: PointerInputChange? = null
            // The stylus move must exceeds touch slop before long press timeout.
            while (true) {
                val pointerEvent = awaitPointerEvent(pass)
                // The tracked pointer is consumed or lifted, stop tracking.
                val change = pointerEvent.changes.fastFirstOrNull {
                    !it.isConsumed && it.id == firstDown.id && it.pressed
                }
                if (change == null) {
                    break
                }

                val time = change.uptimeMillis - firstDown.uptimeMillis
                if (time >= viewConfiguration.longPressTimeoutMillis) {
                    break
                }

                val offset = change.position - firstDown.position
                if (offset.getDistance() > viewConfiguration.handwritingSlop) {
                    exceedsTouchSlop = change
                    break
                }
            }

            if (exceedsTouchSlop == null || !onHandwritingSlopExceeded.invoke()) {
                return@awaitEachGesture
            }
            exceedsTouchSlop.consume()

            // Consume the remaining changes of this pointer.
            while (true) {
                val pointerEvent = awaitPointerEvent(pass = PointerEventPass.Initial)
                val pointerChange = pointerEvent.changes.fastFirstOrNull {
                    !it.isConsumed && it.id == firstDown.id && it.pressed
                } ?: return@awaitEachGesture
                pointerChange.consume()
            }
        }
    })

    override fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize
    ) {
        suspendingPointerInputModifierNode.onPointerEvent(pointerEvent, pass, bounds)
    }

    override fun onCancelPointerInput() {
        suspendingPointerInputModifierNode.onCancelPointerInput()
    }

    fun resetPointerInputHandler() {
        suspendingPointerInputModifierNode.resetPointerInputHandler()
    }
}

/**
 *  Whether the platform supports the stylus handwriting or not. This is for platform level support
 *  and NOT for checking whether the IME supports handwriting.
 */
internal expect val isStylusHandwritingSupported: Boolean

/**
 * The amount of the padding added to the handwriting bounds of an editor.
 */
internal val HandwritingBoundsVerticalOffset = 40.dp
internal val HandwritingBoundsHorizontalOffset = 10.dp




© 2015 - 2024 Weber Informatics LLC | Privacy Policy