Surface Dial 交互开发
基于微软官方《Surface Dial 交互》+ Windows.UI.Input API 参考 + 官方教程沉淀。完整版(含示例代码、验收清单、枚举全表)见 references/full-guide.md;只在对某个细节拿不准时才去读它,日常开发看本文件即可。
0. 心智模型(先立住,否则设计必然跑偏)
把 Dial 想成"一只手就能盲操的、带菜单的增量旋钮"。
- 辅助的第二输入设备:非惯用手拨 Dial,惯用手同时写字/绘画/操作。
- 不是精确指针设备(官方原话:not designed for precision pointer input)。任何"用 Dial 精确点选小目标"的需求 = 反模式,直接否掉。
- 每个自定义工具本质上只回答两个问题:转它调什么?按它切什么?
1. 三个手势(只有三个,边界很硬)
| 手势 | 语义 | 开发约束 |
|---|---|---|
| 长按 Press & Hold | 唯一功能 = 弹出命令菜单 | 系统保留。无应用回调、不可拦截、不可重定义。菜单默认屏幕中心,可拖动 |
| 旋转 Rotate | 连续增量调整模拟值 | RotationDeltaInDegrees 是带符号的相对增量,不是绝对角度 → 必须累加 + Clamp。默认约 10 度触发一次 |
| 点击 Click | 等价鼠标左键单击(与旋转角度无关) | 只能映射可逆操作或二进制切换(静音、显隐、播放/暂停)。禁止绑破坏性/不可逆操作 |
点击的反模式(官方明令禁止):
- ❌ 删除/清空/提交等用户无法轻松退回的操作
- ❌ 用点击开关"依赖旋转才能用"的模态工具(如标尺)——用户会卡在模态里出不来。模态开关放界面上的 ToggleButton
2. 输入路由状态机 —— bug 高发区,务必先讲清
长按 / 切后台 长按
应用接管 ◄────────────── 菜单激活态 ────────────► 系统处理
(自定义工具激活) 选中自定义工具/ (菜单吃事件) 选中内置工具 (内置工具激活)
ControlAcquired
精确规则:
| 条件 | RotationChanged |
ButtonClicked |
ScreenContact* |
|---|---|---|---|
| 菜单激活 | ❌ | ❌ | ❌ |
| 内置工具激活 | ❌ | ❌ | ❌ |
| 自定义工具激活 + 应用前台 | ✅ | ✅ | ✅ |
| 自定义工具激活但应用切后台 | ❌(触发 ControlLost) |
❌ | ❌ |
> 推论:自定义工具必须被用户从菜单里选中,应用才会开始收到输入。 "初始化完就等事件"必然失败——要么引导用户选一次,要么主动 Menu.SelectMenuItem(item)。
控制权生命周期:
- ControlAcquired:用户选中了你的自定义工具,或应用回到前台且你的工具仍激活 → 初始化触觉的最佳时机
- ControlLost:应用切后台 / 用户打开菜单 → 复位 UI 高亮、清理临时态、保存现场
3. 菜单规则(硬约束)
- 容量 7 项,第 8 项起进溢出浮出层,需转动查看 → 工具越少越好(官方建议:一个应用往往只需 1 个自定义工具)
- 菜单项没有 Disabled 状态 → 上下文变了只能
Remove,不能置灰 - 清空内置工具必须在添加自定义工具之后,否则系统还原默认集、你的工具被追加在后面
- 永远不要删系统音量
RadialControllerSystemMenuItemKind.Volume Menu.Items只含自定义工具,Menu.IsEnabled控制的是"是否显示自定义工具",不是"关闭整个菜单"- 移除当前激活项后必须
TrySelectPreviouslySelectedMenuItem(),否则状态悬空
内置工具(RadialControllerSystemMenuItemKind):Scroll0 / Zoom1 / UndoRedo2 / Volume3 / NextPreviousTrack4。上下文自适应是系统行为:桌面加亮度、播放媒体加上下曲、Ink 工具加笔类型/笔划粗细/标尺。
4. 旋转灵敏度 × 触觉(必须联动)
void SetPrecision(bool high)
{
_controller.RotationResolutionInDegrees = high ? 1 : 10;
_controller.UseAutomaticHapticFeedback = !high; // <5度必须关触觉
}
- 分辨率 < 5 度时必须关闭触觉,否则持续震动、极不适(官方硬要求)
- 用户可在系统设置里全局关闭振动 → 代码要容忍"发了没反应"
- 系统内置工具(音量等)的触觉代码改不了,只能用户关
- 想自己发波形:在
ControlAcquired里取args.SimpleHapticsController,发送前必须判空(设备可能不支持该波形)
5. 图标与命名(做 UI 时直接抄)
| 项 | 指标 |
|---|---|
| 尺寸 | 64×64 PNG(44×44 是下限,别更小) |
| 背景 | 必须透明 |
| 占比 | 主体填满画面大部分,不留大边距 |
| 高对比度 | 白色图标必须加黑色描边,否则高对比度白主题下不可见 |
| 命名 | 短(要塞进菜单中央圆圈)、直指主操作、会被屏幕阅读器朗读 |
| 风格 | 与内置图标一致(线宽/圆角/视觉重量) |
正例:「滚动」一次说清两个旋转方向;「撤消」主操作明确,"重做"用户自然能推断。
反例:「工具 1」「高级选项」。
6. 技术栈选型
| 场景 | 接入方式 |
|---|---|
| UWP / WinUI 2 | RadialController.CreateForCurrentView()(绑定应用视图,多视图各自调用) |
| WinUI 3 / WPF / WinForms | Interop 绑 HWND:RadialControllerInterop.CreateForWindow(hwnd);配置用 RadialControllerConfigurationInterop.GetForWindow(hwnd) |
| 能力检测 | 进入页面前先 RadialController.IsSupported(),不支持就隐藏 Dial 相关 UI(别留死按钮) |
COM 接口(原生 C++ 用,radialcontrollerinterop.h):
- IRadialControllerInterop — IID 1B0535C9-57AD-45C1-9D79-AD5C34360513 — CreateForWindow(HWND, REFIID, void**)
- IRadialControllerConfigurationInterop — IID 787CDAAC-3186-476D-87E4-B9374A7B9970 — GetForWindow(HWND, REFIID, void**)
最低系统:Windows 10 Anniversary Update(10.0.14393)+ UniversalApiContract v3。
联调前必做:蓝牙配对(长按 Dial 中心至灯闪);设置 → 蓝牙和其他设备 → 滚轮(Win10 旧路径:设备 → Wheel)可配默认工具/振动/惯用手。旋转、触觉、屏幕接触都用模拟器验不了,必须真机。
7. 代码骨架(C#,UWP 形态)
using Windows.Devices.Haptics;
using Windows.UI.Input;
if (!RadialController.IsSupported()) { /* 隐藏 Dial UI */ return; }
_controller = RadialController.CreateForCurrentView();
_controller.RotationResolutionInDegrees = 10;
_controller.UseAutomaticHapticFeedback = true;
// ① 先加自定义工具
_item = RadialControllerMenuItem.CreateFromFontGlyph("调节", "\xE1E3", "Segoe MDL2 Assets");
if (!_controller.Menu.Items.Contains(_item)) _controller.Menu.Items.Add(_item);
// ② 后才能裁内置工具(顺序反了会被系统还原)
RadialControllerConfiguration.GetForCurrentView()
.SetDefaultMenuItems(new[] { RadialControllerSystemMenuItemKind.Volume });
// ③ 事件
_controller.RotationChanged += (s, a) =>
SetValue(_value + a.RotationDeltaInDegrees * _scale); // 增量累加,内部 Clamp
_controller.ButtonClicked += (s, a) => Toggle(); // 只做可逆/二进制
_controller.ControlAcquired += (s, a) => { s.UseAutomaticHapticFeedback = false; _haptics = a.SimpleHapticsController; };
_controller.ControlLost += (s, a) => ClearHighlight();
_controller.ScreenContactStarted += (s, a) => ActivateAt(a.Contact.Position);
_controller.ScreenContactContinued += (s, a) => { if (!HitTest(a.Contact.Position).Equals(_active)) ActivateAt(a.Contact.Position); };
_controller.ScreenContactEnded += (s, a) => ClearHighlight();
自定义工具四种创建方式:CreateFromKnownIcon(系统图标,视觉最统一)/ CreateFromIcon(应用 PNG)/ CreateFromFontGlyph(name, glyph, fontFamily) / CreateFromFontGlyph(..., fontUri)(自定义图标字体)。
RadialControllerMenuKnownIcon 全量:Scroll0 Zoom1 UndoRedo2 Volume3 NextPreviousTrack4 Ruler5 InkColor6 InkThickness7 PenType8。
8. 屏幕接触(Surface Studio 专属,但最有杀伤力)
RadialControllerScreenContact 给 Position(接触点)与 Bounds(Dial 外接矩形)。用法:
1. 命中测试 → 自动切换活动工具:Dial 放到哪块区域,就接管哪块(官方示例:四宫格,放哪个格子控哪个滑块)
2. UI 避让:按 Bounds 调整布局,避开 Dial 本体和用户手臂
3. 放大 Dial 下方区域、上下文调色板
⚠️ 只在自定义工具激活时才上报。另外记得:工具激活时在界面上给明确视觉指示(高亮被控区域),否则用户不知道"现在控的是谁"。
9. 事件收不到?按顺序排查
- 自定义工具被选中了吗?没选中只有
ControlAcquired,没有旋转/点击 - 菜单开着吗?开着 → 全被菜单吃掉
- 激活的是内置工具吗?是 → 收不到
- 应用在后台?→ 已
ControlLost - 旋转没到
RotationResolutionInDegrees?→ 不触发 - 是否跨线程更新 UI?(用 Dispatcher)
10. 设计规范速查
✅ 该做:工具与上下文强对应/数量尽量少/按视图动态增删(含内置)/Add 前先 Contains 查重/高灵敏度关触觉/检测 Dial 时给视觉反馈/点击只绑可逆或二进制/菜单组织保持一致。
❌ 不该做:删音量工具/留与上下文无关的冗余工具(无 Disabled,留着就是噪音)/让惯用手设置影响旋转语义/点击绑不可逆操作/点击切换依赖旋转的模态工具/添加前清空所有内置工具/把 Dial 当精确指针/高灵敏度下开触觉。
适合:创作类参数调节(笔刷大小/透明度/色相)、画布缩放旋转、时间轴 scrub 与播放速度、3D 视角与参数化尺寸、EQ/滤波等音频参数、二进制切换、Surface Studio 屏上上下文工具。
不适合:精确指针定位、文本输入/需键盘场景、不可逆操作一键触发、高频离散选择(用下拉框)、没有连续可调参数的应用(硬做只会变成"会震动的快捷键")。
11. 待实测确认(官方未写明,别想当然)
- "抑制系统菜单" —— 官方 sample README 提到 "Suppress the menu",但
Menu.IsEnabled语义是"是否显示自定义工具"(false时Items为空),并非关闭整个菜单。机制以官方 desktop sample 源码为准 RotationResolutionInDegrees的精确默认值(文档示例以 10 度为低灵敏度档)- Windows 11 设置页的准确路径与文案
RadialControllerMenu.IsEnabled在 Anniversary Update 上不返回值,老系统需兜底
参考
- 主文档:https://learn.microsoft.com/zh-cn/windows/uwp/ui-input/windows-wheel-interactions
- 教程:https://learn.microsoft.com/zh-cn/windows/uwp/ui-input/radialcontroller-walkthrough
- API:
https://learn.microsoft.com/zh-cn/uwp/api/windows.ui.input.radialcontroller - Win32 Interop:
https://learn.microsoft.com/zh-cn/windows/win32/api/radialcontrollerinterop/nn-radialcontrollerinterop-iradialcontrollerinterop - UWP 示例:
https://github.com/microsoft/Windows-universal-samples/tree/main/Samples/RadialController - 桌面示例:
https://aka.ms/radialcontrollerclassicsample