高级手写笔功能

Android 和 ChromeOS 提供了各种 API,可帮助你构建为用户提供卓越手写笔体验的应用。MotionEvent 类公开了手写笔与屏幕交互的信息,包括手写笔压力、方向、倾斜、悬停和防手掌误触。低延迟图形和运动预测库可增强手写笔的屏幕渲染效果,提供自然流畅的纸笔书写体验。

MotionEvent

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

事件数据

要访问手写笔 MotionEvent 对象,请将 pointerInteropFilter 修饰符添加到绘图表面。实现一个 ViewModel 类,其中包含一个处理运动事件的方法;将该方法作为 pointerInteropFilter 修饰符的 onTouchEvent lambda 传递。

@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun DrawArea(modifier: Modifier = Modifier) {
    Canvas(
        modifier = modifier
            .clipToBounds()
            .pointerInteropFilter {
                viewModel.processMotionEvent(it)
            }

    ) {
        // Drawing code here.
    }
}

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. 手写笔指向左侧,约为 -.57 弧度。

倾斜

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

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

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

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

Stylus inclined about 40 degrees from the screen surface.
图 4. 手写笔倾斜约 .785 弧度,或从垂直方向倾斜 45 度。

悬停

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

手写笔悬停可用于预览画笔大小或指示即将选择某个按钮。

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

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

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

防手掌误触、导航和意外输入

有时多点触控屏幕会记录意外触摸,例如,当用户在手写时自然地将手放在屏幕上以获得支撑。防手掌误触是一种检测此行为的机制,并通知你上次的 MotionEvent 集合应被取消。

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

ACTION_CANCEL 和 FLAG_CANCELED

ACTION_CANCELFLAG_CANCELED 都旨在通知你之前的 MotionEvent 集合应从上次 ACTION_DOWN 中取消,这样你就可以(例如)撤消绘图应用中给定指针的上次笔触。

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

如果设置了此标志,你需要撤消此指针从上次 ACTION_DOWN 以来的最后一个 MotionEvent 集合。

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

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

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

如果应用是全屏的,并且边缘附近有可操作的元素(例如绘图或笔记应用的画布),则从屏幕底部滑动以显示导航或将应用移到后台可能会导致画布上出现意外触摸。

图 7. 滑动姿态将应用移到后台。

为防止手势在你的应用中触发意外触摸,你可以利用 边衬区ACTION_CANCEL

另请参阅防手掌误触、导航和意外输入部分。

使用 setSystemBarsBehavior() 方法和 WindowInsetsControllerBEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 来防止导航手势导致意外触摸事件。

// 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 到 1000Hz。
  • 输入处理(应用):对用户输入应用颜色、图形效果和变换。
  • 图形渲染(操作系统 + 硬件):缓冲区交换、硬件处理。

低延迟图形

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 intent,它使你的应用能够在锁屏上启动笔记活动。

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

借助 ML Kit 数字墨迹识别,你的应用可以识别数字表面上数百种语言的手写文本。你还可以对草图进行分类。

ML Kit 提供了 Ink.Stroke.Builder 类来创建 Ink 对象,这些对象可以由机器学习模型处理,将手写内容转换为文本。

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

请参阅数字墨迹识别以了解更多信息。

其他资源

开发者指南

Codelabs