高级触控笔功能

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 对象提供与界面事件以下几方面相关的数据:

  • 操作:与设备进行物理交互,例如轻触屏幕、在屏幕表面上移动指针、将指针悬停在屏幕表面上
  • 指针:与屏幕互动的对象的标识符,例如手指、触控笔、鼠标
  • 轴:数据类型 - 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_UP 的一组 MotionEvent 操作称为动作集。

指针

大多数屏幕都是多点触控的:系统会为每个手指、触控笔、鼠标或其他与屏幕互动的指向对象分配一个指针。借助指针索引,您可以获取特定指针的轴信息,例如第一个手指或第二个手指轻触屏幕的位置。

指针索引的范围是 0 到 MotionEvent#pointerCount() 返回的指针数减 1。

您可以使用 getAxisValue(axis, pointerIndex) 方法访问指针的轴值。如果省略了触控点索引,系统会返回第一个指针(即指针零 (0))的值。

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

如需详细了解指针,请参阅处理多点触控手势

触控笔输入

您可以使用 TOOL_TYPE_STYLUS 过滤触控笔输入:

Kotlin

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

Java

boolean isStylus = TOOL_TYPE_STYLUS == event.getToolType(pointerIndex);

触控笔还可以通过 TOOL_TYPE_ERASER 报告其已用作橡皮擦:

Kotlin

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

Java

boolean 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 坐标:

屏幕上映射到 x 和 y 坐标的触控笔绘图。
图 1. 触控笔指针的 x 和 y 屏幕坐标。

压力

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

触摸屏或触控板的压力值介于 0(无压力)到 1 之间,但可以根据屏幕校准返回更大的值。

表示从低到高进行连续施压的触控笔触。左侧笔触较窄且较浅,表示压力较低。笔触从左到右越来越宽,颜色越来越深,在最右侧变得最宽最深,表示压力最高。
图 2. 压力表示 — 左侧为低压,右侧为高压。

方向

方向表示触控笔所指方向。

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

对于触控笔,返回的方向值为顺时针 0 到 pi (π) 或逆时针 0 到 -pi 之间的弧度值。

有了方向,您可以实现逼真的画笔体验。例如,如果使用扁平画笔,则扁平画笔的宽度取决于触控笔方向。

图 3. 触控笔指向左边约负 0.57 弧度。

倾斜度

倾斜度是指触控笔相对于屏幕的倾斜度。

倾斜度返回的触控笔弧度值为正,其中 0 表示垂直于屏幕,而 π/2 表示与屏幕平行。

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

倾斜度可用于重现尽可能接近真实工具的逼真效果,例如使用倾斜的铅笔模仿阴影。

触控笔与屏幕表面之间倾斜了约 40 度。
图 4. 触控笔倾斜了约 0.785 弧度,或与垂直方向倾斜了 45 度。

悬停

触控笔与屏幕之间的距离可通过 getAxisValue(AXIS_DISTANCE) 获取。当触控笔渐渐远离屏幕时,该方法会返回 0.0(与屏幕接触)到较高值之间的值。屏幕和触控笔头(笔尖)之间的悬停距离取决于屏幕和触控笔的制造商。由于不同实现方式可能会有所不同,因此请勿让关键应用功能依赖其精确值。

触控笔悬停可用于预览画笔的粗细,或指示将要选择某个按钮。

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

注意:Compose 提供了会影响界面元素互动状态的修饰符:

  • 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 表示指针上移是由于用户无意间轻触所致。该标志通常在用户不小心轻触屏幕时设置,例如抓握设备或将手掌放在屏幕上时。

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

Kotlin

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

Java

boolean cancel = (event.getFlags() & FLAG_CANCELED) == FLAG_CANCELED;

如果已设置此标志,则需要从此指针的最后一次 ACTION_DOWN 操作中撤消最后一组 MotionEvent 集。

ACTION_CANCEL 一样,可以通过 getPointerId(actionIndex) 找到指针。

图 6. 触控笔触和手掌轻触时创建的 MotionEvent 集。取消手掌轻触操作,并重新渲染展示效果。

全屏、无边框和导航手势

如果应用为全屏显示,并且在边缘附近有可操作的元素(例如绘图或记事应用的画布),则从屏幕底部滑动以显示导航或将应用移至后台可能会导致画布上出现不必要的轻触。

图 7. 滑动手势即可将应用移至后台。

为了防止手势在应用中触发不必要的轻触,您可以利用边衬区ACTION_CANCEL

另请参阅防手掌误触、导航和不必要的输入部分。

使用 setSystemBarsBehavior() 方法和 WindowInsetsControllerBEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 可防止导航手势导致不必要的轻触事件:

Kotlin

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

Java

// Configure the behavior of the hidden system bars.
windowInsetsController.setSystemBarsBehavior(
    WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);

如需详细了解边衬区和手势管理,请参阅:

低延迟时间

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

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

  • 硬件渲染
延迟会导致所渲染笔触滞后于触控笔位置。所渲染笔触和触控笔位置之间的时间差即为延迟时间。
图 8.延迟会导致所渲染笔触滞后于触控笔位置。

延迟来源

  • 在触摸屏(硬件)上注册触控笔:当触控笔和操作系统通信以注册和同步时,进行初始无线连接。
  • 触摸采样率(硬件):触摸屏每秒检查指针是否轻触屏幕表面的次数,范围介于 60 到 1, 000Hz 之间。
  • 输入处理(应用):对用户输入应用颜色、图形效果和转换功能。
  • 图形渲染(操作系统 + 硬件):缓冲区交换、硬件处理。

低延迟图形

Jetpack 低延迟图形库可以缩短用户输入与屏幕渲染之间的处理时间。

该库可以避免多缓冲区渲染,并利用前端缓冲区渲染技术(即直接写入屏幕),从而缩短处理时间。

前端缓冲区渲染

前端缓冲区是用于屏幕渲染的内存区。这是能够直接在屏幕上绘图的最近应用。借助低延迟库,应用能够直接渲染到前端缓冲区。这样可以通过防止缓冲区交换来提高性能,常规多缓冲区渲染或双缓冲区渲染(最常见的情况)可能会发生缓冲区交换。

应用将数据写入缓冲区,然后从屏幕缓冲区读取数据。
图 9. 前端缓冲区渲染。
应用将数据写入多缓冲区,然后多缓冲区与屏幕缓冲区进行交换。应用从屏幕缓冲区读取数据。
图 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 使用回调以最优化的方式渲染数据。

Kotlin

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.
   }
}

Java

GLFrontBufferedRenderer.Callback<DATA_TYPE> callbacks =
    new GLFrontBufferedRenderer.Callback<DATA_TYPE>() {
        @Override
        public void onDrawFrontBufferedLayer(@NonNull EGLManager eglManager,
            @NonNull BufferInfo bufferInfo,
            @NonNull float[] transform,
            DATA_TYPE data_type) {
                // OpenGL for front buffer, short, affecting small area of the screen.
        }

    @Override
    public void onDrawDoubleBufferedLayer(@NonNull EGLManager eglManager,
        @NonNull BufferInfo bufferInfo,
        @NonNull float[] transform,
        @NonNull Collection<? extends DATA_TYPE> collection) {
            // OpenGL full scene rendering.
    }
};
声明一个 GLFrontBufferedRenderer 实例

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

Kotlin

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

Java

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

前端缓冲区渲染会在您调用 renderFrontBufferedLayer() 方法时开始,这会触发 onDrawFrontBufferedLayer() 回调。

当您调用 commit() 函数时,会触发 onDrawMultiDoubleBufferedLayer() 回调。

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

您可以使用 requestUnbufferedDispatch() 要求输入系统不批量处理动作事件,而是在可用时立即传送这些事件:

Kotlin

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()
   }
}

Java

switch (motionEvent.getAction()) {
   case MotionEvent.ACTION_DOWN: {
       // Deliver input events as soon as they arrive.
       surfaceView.requestUnbufferedDispatch(motionEvent);

       // Pointer is in contact with the screen.
       glFrontBufferRenderer.renderFrontBufferedLayer(DATA_TYPE);
   }
   break;
   case MotionEvent.ACTION_MOVE: {
       // Pointer is moving.
       glFrontBufferRenderer.renderFrontBufferedLayer(DATA_TYPE);
   }
   break;
   case MotionEvent.ACTION_UP: {
       // Pointer is not in contact in the screen.
       glFrontBufferRenderer.commit();
   }
   break;
   case MotionEvent.ACTION_CANCEL: {
       // Cancel front buffer; remove last motion set from the screen.
       glFrontBufferRenderer.cancel();
   }
   break;
}

渲染注意事项

正确做法

在屏幕的一小块区域内进行手写、绘图和素描。

错误做法

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

画面撕裂

当屏幕刷新并同时修改屏幕缓冲区时,会发生画面撕裂。屏幕的一部分显示新数据,而另一部分则显示旧数据。

由于屏幕刷新时所致画面撕裂,Android 图片的上下部分不再对齐。
图 11. 屏幕从上往下刷新时,会发生画面撕裂。

动作预测

Jetpack 动作预测库通过估算用户的笔触路径并为渲染程序提供临时的人造点来缩短感知延迟时间。

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

预测的 MotionEvent 对象只是估算值。预测事件可以缩短感知延迟时间,但一旦收到预测数据,就必须将其替换为实际 MotionEvent 数据。

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

延迟会导致所渲染笔触滞后于触控笔位置。通过预测点弥补笔触和触控笔之间的时间差。其余时间差即为感知延迟时间。
图 12. 可以通过动作预测缩短延迟时间。

依赖项

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

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

实现

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

  • record():将 MotionEvent 对象存储为用户的操作记录
  • predict():返回预测的 MotionEvent
声明一个 MotionEventPredictor 实例

Kotlin

var motionEventPredictor = MotionEventPredictor.newInstance(view)

Java

MotionEventPredictor motionEventPredictor = MotionEventPredictor.newInstance(surfaceView);
向预测程序提供数据

Kotlin

motionEventPredictor.record(motionEvent)

Java

motionEventPredictor.record(motionEvent);
预测

Kotlin

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

Java

switch (motionEvent.getAction()) {
   case MotionEvent.ACTION_MOVE: {
       MotionEvent predictedMotionEvent = motionEventPredictor.predict();
       if(predictedMotionEvent != null) {
           // use predicted MotionEvent to inject a new artificial point
       }
   }
   break;
}

动作预测的注意事项

正确做法

添加新的预测点后移除旧预测点。

错误做法

请勿将预测点用于最终渲染。

记事应用

借助 ChromeOS,可以声明应用具有一些记事操作功能。

如需在 ChromeOS 上将应用注册为记事应用,请参阅输入兼容性

如需在 Android 上将应用注册为记事应用,请参阅创建记事应用

Android 14(API 级别 34)引入了 ACTION_CREATE_NOTE intent,可让您的应用在锁定屏幕上启动记事 activity。

利用机器学习套件进行数字手写识别

借助机器学习套件数字手写识别功能,您的应用可以识别数字平面上数百种语言的手写文本。您还可以对素描进行分类。

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

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

如需了解详情,请参阅数字手写识别

其他资源

开发者指南

Codelab