高级手写笔功能

Android 和 ChromeOS 提供了多种 API 来帮助您构建能够为用户提供出色手写笔体验的应用。MotionEvent 类公开有关手写笔与屏幕交互的信息,包括手写笔压力、方向、倾斜、悬停和手掌检测。低延迟图形和运动预测库增强了手写笔在屏幕上的渲染效果,提供了一种自然、类似笔和纸的体验。

MotionEvent

MotionEvent 类表示用户输入交互,例如触摸指针在屏幕上的位置和移动。对于手写笔输入,MotionEvent 还公开压力、方向、倾斜和悬停数据。

事件数据

要访问 MotionEvent 数据,请将 pointerInput 修饰符添加到组件中

@Composable
fun Greeting() {
    Text(
        text = "Hello, Android!", textAlign = TextAlign.Center, style = TextStyle(fontSize = 5.em),
        modifier = Modifier
            .pointerInput(Unit) {
                awaitEachGesture {
                    while (true) {
                        val event = awaitPointerEvent()
                        event.changes.forEach { println(it) }
                    }
                }
            },
    )
}

MotionEvent 对象提供与以下 UI 事件方面相关的数据

  • 操作:与设备的物理交互 - 触摸屏幕、将指针移过屏幕表面、将指针悬停在屏幕表面上
  • 指针:与屏幕交互的对象标识符 - 手指、手写笔、鼠标
  • 轴:数据类型 - x 和 y 坐标、压力、倾斜、方向和悬停(距离)

操作

要实现手写笔支持,您需要了解用户正在执行的操作。

MotionEvent 提供了各种 ACTION 常量来定义运动事件。对于手写笔来说,最重要的操作包括以下内容

操作 描述
ACTION_DOWN
ACTION_POINTER_DOWN
指针已与屏幕接触。
ACTION_MOVE 指针正在屏幕上移动。
ACTION_UP
ACTION_POINTER_UP
指针不再与屏幕接触
ACTION_CANCEL 当应取消先前或当前运动集时。

当发生 ACTION_DOWN 时,您的应用可以执行诸如开始新笔划的任务,使用 ACTION_MOVE 绘制笔划,并在触发 ACTION_UP 时完成笔划。

对于给定指针,从 ACTION_DOWNACTION_UPMotionEvent 操作集称为运动集。

指针

大多数屏幕是多点触控的:系统为每个手指、手写笔、鼠标或与屏幕交互的其他指向对象分配一个指针。指针索引使您能够获取特定指针的轴信息,例如触摸屏幕的第一个手指或第二个手指的位置。

指针索引的范围从零到 MotionEvent#pointerCount() 返回的指针数量减 1。

可以使用 getAxisValue(axis, pointerIndex) 方法访问指针的轴值。当省略指针索引时,系统将返回第一个指针(指针 0)的值。

MotionEvent 对象包含有关所用指针类型的信息。您可以通过遍历指针索引并调用 getToolType(pointerIndex) 方法来获取指针类型。

要了解有关指针的更多信息,请参阅 处理多点触控手势.

手写笔输入

您可以使用 TOOL_TYPE_STYLUS 筛选手写笔输入

val isStylus = TOOL_TYPE_STYLUS == event.getToolType(pointerIndex)

手写笔还可以报告它被用作橡皮擦,使用 TOOL_TYPE_ERASER

val isEraser = TOOL_TYPE_ERASER == event.getToolType(pointerIndex)

手写笔轴数据

ACTION_DOWNACTION_MOVE 提供有关手写笔的轴数据,即 x 和 y 坐标、压力、方向、倾斜和悬停。

要启用对该数据的访问,MotionEvent API 提供了 getAxisValue(int),其中参数是以下任何轴标识符

getAxisValue() 的返回值
AXIS_X 运动事件的 X 坐标。
AXIS_Y 运动事件的 Y 坐标。
AXIS_PRESSURE 对于触摸屏或触摸板,是指尖、手写笔或其他指针施加的压力。对于鼠标或轨迹球,如果按下主按钮,则为 1,否则为 0。
AXIS_ORIENTATION 对于触摸屏或触摸板,是指尖、手写笔或其他指针相对于设备垂直平面的方向。
AXIS_TILT 手写笔的倾斜角(以弧度为单位)。
AXIS_DISTANCE 手写笔到屏幕的距离。

例如,MotionEvent.getAxisValue(AXIS_X) 返回第一个指针的 x 坐标。

另请参阅 处理多点触控手势.

位置

您可以使用以下调用检索指针的 x 和 y 坐标

Stylus drawing on screen with x and y coordinates mapped.
图 1. 手写笔指针的 X 和 Y 屏幕坐标。

压力

您可以使用 MotionEvent#getAxisValue(AXIS_PRESSURE) 检索指针压力,或者对于第一个指针,使用 MotionEvent#getPressure()

触摸屏或触摸板的压力值介于 0(无压力)和 1 之间,但根据屏幕校准,可能会返回更高的值。

Stylus stroke that represents a continuum of low to high pressure. The stroke is narrow and faint on the left, indicating low pressure. The stroke becomes wider and darker from left to right until it is widest and darkest on the far right, indicating highest pressure.
图 2. 压力表示 - 左侧压力低,右侧压力高。

方向

方向指示手写笔指向的方向。

可以使用 getAxisValue(AXIS_ORIENTATION)getOrientation()(对于第一个指针)检索指针方向。

对于手写笔,方向以弧度值返回,介于 0 到 pi (𝛑) 之间,顺时针方向或 0 到 -pi 逆时针方向。

方向使您能够实现真实的笔刷。例如,如果手写笔代表一把扁平的刷子,那么扁平刷子的宽度取决于手写笔的方向。

图 3. 手写笔指向左侧,约为负 0.57 弧度。

倾斜

倾斜测量手写笔相对于屏幕的倾斜度。

倾斜以弧度返回手写笔的正角,其中零垂直于屏幕,𝛑/2 平放在表面上。

可以使用 getAxisValue(AXIS_TILT)(没有第一个指针的快捷方式)检索倾斜角。

倾斜可用于尽可能逼真地再现真实工具,例如模拟用倾斜的铅笔进行阴影处理。

Stylus inclined about 40 degrees from the screen surface.
图 4. 手写笔以大约 0.785 弧度(或相对于垂直方向的 45 度)倾斜。

悬停

可以使用 getAxisValue(AXIS_DISTANCE) 获取手写笔到屏幕的距离。该方法返回一个值,从 0.0(与屏幕接触)到更高的值,因为手写笔远离屏幕。屏幕和手写笔笔尖(点)之间的悬停距离取决于屏幕和手写笔的制造商。由于实现可能有所不同,因此不要依赖于应用关键功能的精确值。

手写笔悬停可用于预览笔刷大小或指示要选择按钮。

图 5. 手写笔悬停在屏幕上。即使手写笔没有接触屏幕表面,应用也会做出反应。

注意: Compose 提供了影响 UI 元素交互状态的修饰符

  • hoverable: 使用指针进入和退出事件配置组件以使其可悬停。
  • indication: 当交互发生时,为此组件绘制视觉效果。

手掌拒绝、导航和意外输入

有时多点触控屏幕会注册意外的触摸,例如,当用户在手写时自然地将手放在屏幕上以支撑时。手掌拒绝是一种机制,可以检测到这种行为,并通知您应取消最后一个 MotionEvent 集。

因此,您必须保留用户输入的历史记录,以便可以从屏幕中删除意外的触摸,并重新渲染合法的用户输入。

ACTION_CANCEL 和 FLAG_CANCELED

ACTION_CANCELFLAG_CANCELED 都旨在通知您应从最后一个 ACTION_DOWN 取消先前的 MotionEvent 集,以便您可以(例如)撤消特定指针的绘图应用的最后一个笔划。

ACTION_CANCEL

在 Android 1.0(API 级别 1)中添加

ACTION_CANCEL 指示应取消先前的运动事件集。

当检测到以下任何情况时,将触发 ACTION_CANCEL

  • 导航手势
  • 手掌拒绝

当触发 ACTION_CANCEL 时,您应该使用 getPointerId(getActionIndex()) 识别活动指针。然后,从输入历史记录中删除使用该指针创建的笔划,并重新渲染场景。

FLAG_CANCELED

在 Android 13(API 级别 33)中添加

FLAG_CANCELED 指示向上移动的指针是用户无意触碰的。当用户意外触碰屏幕时,通常会设置此标志,例如,通过握住设备或将手掌放在屏幕上。

您可以按如下方式访问标志值

val cancel = (event.flags and FLAG_CANCELED) == FLAG_CANCELED

如果设置了标志,则需要撤消上次由该指针触发的最后一个 MotionEvent 设置,从最后一个 ACTION_DOWN 开始。

类似于 ACTION_CANCEL,可以使用 getPointerId(actionIndex) 找到该指针。

图 6. 手写笔笔触和手掌触碰都会创建 MotionEvent 集。手掌触碰被取消,显示屏重新渲染。

全屏、边缘到边缘和导航手势

如果一个应用程序是全屏的,并且在边缘附近有可操作元素,例如绘图或笔记应用程序的画布,从屏幕底部向上滑动以显示导航或将应用程序移至后台可能会导致意外触碰画布。

图 7. 使用滑动手势将应用程序移至后台。

为了防止手势在应用程序中触发意外触碰,您可以利用 内边距ACTION_CANCEL

另请参阅 手掌拒绝、导航和意外输入 部分。

使用 setSystemBarsBehavior() 方法和 BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 属性(属于 WindowInsetsController)来防止导航手势导致意外触碰事件。

// Configure the behavior of the hidden system bars.
windowInsetsController.systemBarsBehavior =
    WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE

要详细了解内边距和手势管理,请参阅

低延迟

延迟是指硬件、系统和应用程序处理和渲染用户输入所需的时间。

延迟 = 硬件和操作系统输入处理 + 应用程序处理 + 系统合成

  • 硬件渲染
Latency causes the rendered stroke to lag behind the stylus position. The gap between the stroke rendered and the stylus position represents the latency.
图 8. 延迟会导致渲染的笔触滞后于手写笔的位置。

延迟的来源

  • 将手写笔注册到触摸屏(硬件):手写笔和操作系统首次进行无线连接以进行注册和同步。
  • 触摸采样率(硬件):触摸屏每秒检查指针是否接触表面的次数,范围为 60 到 1000 赫兹。
  • 输入处理(应用程序):对用户输入应用颜色、图形效果和变换。
  • 图形渲染(操作系统 + 硬件):缓冲区交换、硬件处理。

低延迟图形

通过使用 Jetpack 低延迟图形库,可以减少用户输入与屏幕上渲染之间处理时间。

该库通过避免多缓冲区渲染并利用前缓冲区渲染技术来减少处理时间,这意味着直接写入屏幕。

前缓冲区渲染

前缓冲区是屏幕用于渲染的内存。它是应用程序最接近直接绘制到屏幕的方式。低延迟库使应用程序能够直接渲染到前缓冲区。通过防止缓冲区交换,可以提高性能,缓冲区交换可能会在常规多缓冲区渲染或双缓冲区渲染(最常见的情况)中发生。

App writes to screen buffer and reads from screen buffer.
图 9. 前缓冲区渲染。
App writes to multi-buffer, which swaps with screen buffer. App reads from screen buffer.
图 10. 多缓冲区渲染。

虽然前缓冲区渲染是渲染屏幕一小部分区域的优秀技术,但它并非旨在用于刷新整个屏幕。使用前缓冲区渲染时,应用程序将内容渲染到一个缓冲区中,显示屏从该缓冲区中读取内容。因此,可能会出现渲染伪影或 撕裂 (请参见下文)。

低延迟库适用于 Android 10(API 级别 29)及更高版本,以及运行 Android 10(API 级别 29)及更高版本的 ChromeOS 设备。

依赖项

低延迟库提供前缓冲区渲染实现所需的组件。该库作为依赖项添加到应用程序模块的 build.gradle 文件中

dependencies {
    implementation "androidx.graphics:graphics-core:1.0.0-alpha03"
}

GLFrontBufferRenderer 回调

低延迟库包含 GLFrontBufferRenderer.Callback 接口,该接口定义以下方法

低延迟库对您使用 GLFrontBufferRenderer 的数据类型不做要求。

但是,该库将数据处理为数百个数据点的流;因此,请设计您的数据以优化内存使用和分配。

回调

若要启用渲染回调,请实现 GLFrontBufferedRenderer.Callback 并覆盖 onDrawFrontBufferedLayer()onDrawDoubleBufferedLayer()GLFrontBufferedRenderer 使用回调以尽可能优化的方式渲染您的数据。

val callback = object: GLFrontBufferedRenderer.Callback<DATA_TYPE> {
   override fun onDrawFrontBufferedLayer(
       eglManager: EGLManager,
       bufferInfo: BufferInfo,
       transform: FloatArray,
       param: DATA_TYPE
   ) {
       // OpenGL for front buffer, short, affecting small area of the screen.
   }
   override fun onDrawMultiDoubleBufferedLayer(
       eglManager: EGLManager,
       bufferInfo: BufferInfo,
       transform: FloatArray,
       params: Collection<DATA_TYPE>
   ) {
       // OpenGL full scene rendering.
   }
}
声明 GLFrontBufferedRenderer 的实例

通过提供您之前创建的 SurfaceView 和回调来准备 GLFrontBufferedRendererGLFrontBufferedRenderer 使用您的回调优化对前缓冲区和双缓冲区的渲染。

var glFrontBufferRenderer = GLFrontBufferedRenderer<DATA_TYPE>(surfaceView, callbacks)
渲染

当您调用 renderFrontBufferedLayer() 方法时,前缓冲区渲染开始,该方法将触发 onDrawFrontBufferedLayer() 回调。

当您调用 commit() 函数时,双缓冲区渲染恢复,该函数将触发 onDrawMultiDoubleBufferedLayer() 回调。

在以下示例中,当用户开始在屏幕上绘制(ACTION_DOWN)并移动指针(ACTION_MOVE)时,该过程会渲染到前缓冲区(快速渲染)。当指针离开屏幕表面(ACTION_UP)时,该过程会渲染到双缓冲区。

您可以使用 requestUnbufferedDispatch() 请求输入系统不要批处理运动事件,而是尽快传递它们。

when (motionEvent.action) {
   MotionEvent.ACTION_DOWN -> {
       // Deliver input events as soon as they arrive.
       view.requestUnbufferedDispatch(motionEvent)
       // Pointer is in contact with the screen.
       glFrontBufferRenderer.renderFrontBufferedLayer(DATA_TYPE)
   }
   MotionEvent.ACTION_MOVE -> {
       // Pointer is moving.
       glFrontBufferRenderer.renderFrontBufferedLayer(DATA_TYPE)
   }
   MotionEvent.ACTION_UP -> {
       // Pointer is not in contact in the screen.
       glFrontBufferRenderer.commit()
   }
   MotionEvent.CANCEL -> {
       // Cancel front buffer; remove last motion set from the screen.
       glFrontBufferRenderer.cancel()
   }
}

渲染的注意事项

✓ 可以

屏幕的较小部分,手写、绘图、素描。

✗ 不可以

全屏更新、平移、缩放。可能会导致撕裂。

撕裂

撕裂是指屏幕在屏幕缓冲区同时被修改时刷新。屏幕的一部分显示新数据,而另一部分显示旧数据。

Upper and lower parts of Android image are misaligned due to tearing as screen refreshes.
图 11. 屏幕从上到下刷新时的撕裂。

运动预测

通过使用 Jetpack 运动预测库,可以估计用户笔触路径并向渲染器提供临时的虚拟点,从而降低感知延迟。

运动预测库获取作为 MotionEvent 对象的真实用户输入。这些对象包含有关 x 和 y 坐标、压力以及时间的信息,运动预测器将利用这些信息来预测未来的 MotionEvent 对象。

预测的 MotionEvent 对象仅是估计值。预测事件可以降低感知延迟,但必须使用实际的 MotionEvent 数据替换预测数据,一旦收到这些数据。

运动预测库适用于 Android 4.4(API 级别 19)及更高版本,以及运行 Android 9(API 级别 28)及更高版本的 ChromeOS 设备。

Latency causes the rendered stroke to lag behind the stylus position. The gap between the stroke and stylus is filled with prediction points. The remaining gap is the perceived latency.
图 12. 运动预测降低了延迟。

依赖项

运动预测库提供预测的实现。该库作为依赖项添加到应用程序模块的 build.gradle 文件中

dependencies {
    implementation "androidx.input:input-motionprediction:1.0.0-beta01"
}

实现

运动预测库包含 MotionEventPredictor 接口,该接口定义以下方法

  • record(): 将 MotionEvent 对象存储为用户操作的记录
  • predict(): 返回预测的 MotionEvent
声明 MotionEventPredictor 的实例
var motionEventPredictor = MotionEventPredictor.newInstance(view)
用数据来训练预测器
motionEventPredictor.record(motionEvent)
预测

when (motionEvent.action) {
   MotionEvent.ACTION_MOVE -> {
       val predictedMotionEvent = motionEventPredictor?.predict()
       if(predictedMotionEvent != null) {
            // use predicted MotionEvent to inject a new artificial point
       }
   }
}

运动预测的注意事项

✓ 可以

添加新的预测点时,删除预测点。

✗ 不可以

不要使用预测点进行最终渲染。

笔记应用程序

ChromeOS 使您的应用程序能够声明一些笔记操作。

若要在 ChromeOS 上将应用程序注册为笔记应用程序,请参阅 输入兼容性

若要在 Android 上将应用程序注册为笔记应用程序,请参阅 创建笔记应用程序

Android 14(API 级别 34)引入了 ACTION_CREATE_NOTE 意图,使您的应用程序能够在锁定屏幕上启动笔记活动。

使用 ML Kit 进行数字墨迹识别

通过使用 ML Kit 数字墨迹识别,您的应用程序能够识别数字表面上数百种语言的手写文字。您还可以对草图进行分类。

ML Kit 提供 Ink.Stroke.Builder 类,用于创建可以由机器学习模型处理的 Ink 对象,以将手写文字转换为文本。

除了手写识别之外,该模型还能够识别 手势,例如删除和圈选。

请参阅 数字墨迹识别 了解详细信息。

其他资源

开发者指南

Codelabs