IM类应用适配PC快捷键最佳实践
一、切换发送消息快捷键场景适配
场景介绍
IM(Instant Messaging,即时通讯)类应用在发送消息时,通常需要使用Enter键或Ctrl+Enter键作为发送消息快捷键,在使用TextArea/RichEditor组件作为输入组件时,Enter按键事件会被输入组件消费导致无法正确在onKeyEvent事件中响应事件。针对此场景,可以在应用内维护Ctrl键按下状态,通过keyEvent事件刷新状态,并使用输入组件的onSubmit回调作为响应入口,正确响应消息发送事件。
功能介绍
1、维护Ctrl按键状态并通过keyEvent事件刷新:
@State ctrlFlag: boolean = false; // 维护ctrl按键状态
.onKeyEvent((event: KeyEvent) => {
if (event.getModifierKeyState) {
// 每次keyEvent更新ctrl按键状态
this.ctrlFlag = event.getModifierKeyState(['Ctrl']);
}
})
2、响应onSubmit事件发送消息:
.enterKeyType(this.enterTypes[this.switchIndex]) // 通过switchIndex切换当前组件的enterKeyType
.onSubmit((enterKey: EnterKeyType, event: SubmitEvent) => {
console.info('onSubmit', enterKey);
// 通过enterKeyType和ctrl状态判断当前执行发送还是换行逻辑
if ((enterKey === EnterKeyType.Send && !this.ctrlFlag) || (enterKey === EnterKeyType.NEW_LINE && this.ctrlFlag)) {
this.submit() // 执行发送逻辑
} else if (enterKey === EnterKeyType.Send && this.ctrlFlag){
this.controller.addTextSpan('\n'); // 执行换行逻辑
}
event.keepEditableState(); // 输入框保持输入态,保证输入连续性
})
3、使用Select组件切换消息发送快捷键:
@State enterTypes: Array<EnterKeyType> = [EnterKeyType.Send, EnterKeyType.NEW_LINE]
@State switchText: string = 'Enter'
@State switchIndex: number = 0;
Select([{ value: 'Enter' }, { value: 'Ctrl + Enter' }])
.selected(this.switchIndex) // 使用switchIndex作为当前选择模式的索引
.font({ size: 16, weight: 500 })
.value(this.switchText)
.fontColor('#182431')
.selectedOptionFont({ size: 16, weight: 400 })
.optionFont({ size: 16, weight: 400 })
.menuAlign(MenuAlignType.START, { dx: 0, dy: 0 })
.optionWidth(200)
.optionHeight(300)
.onSelect((index: number, text?: string | undefined) => {
console.info('Select:' + index)
this.switchIndex = index;
this.switchText = text as string;
})
4、使用Button组件作为发送消息的按钮:
Button('Submit').onClick(() => {
// submit为执行发送消息的逻辑处理
this.submit();
})
规范标准
按键事件数据流
按键事件由外设键盘等设备触发,经驱动和多模处理转换后发送给当前获焦的窗口,窗口获取到事件后,会尝试分发三次事件。三次分发的优先顺序如下,一旦事件被消费,则跳过后续分发流程。
1、首先分发给ArkUI框架用于触发获焦组件绑定的onKeyPreIme回调和页面快捷键。
2、再向输入法分发,输入法会消费按键用作输入。
3、再次将事件发给ArkUI框架,用于响应系统默认Key事件(例如走焦),以及获焦组件绑定的onKeyEvent回调。
因此,当某输入框组件获焦,且打开了输入法,此时大部分按键事件均会被输入法消费。例如字母键会被输入法用来向输入框中输入对应字母字符、方向键会被输入法用来切换选中备选词。如果在此基础上给输入框组件绑定了快捷键,那么快捷键会优先响应事件,事件也不再会被输入法消费。
按键事件到ArkUI框架之后,会先找到完整的父子节点获焦链。从叶子节点到根节点,逐一发送按键事件。
效果示例
参考Demo:
@Entry @Component struct Index { @State text: string = ''; @State enterTypes: Array<EnterKeyType> = [EnterKeyType.Send, EnterKeyType.NEW_LINE]; @State switchText: string = 'Enter'; @State switchIndex: number = 0; @State ctrlFlag: boolean = false; // 维护ctrl按键状态 controller: RichEditorController = new RichEditorController(); build() { Column({ space: 20 }) { Row({ space: 20 }) { Text(this.text) .fontSize(16) .backgroundColor(0xEEEEEE) .margin({ top: 20, right: 20, bottom: 20, left: 20 }) .border({ width: 1, color: Color.Black, style: BorderStyle.Solid }) .width(640).height(360).align(Alignment.TopStart) } .margin({ top: 20}) Row({ space: 20 }) { RichEditor({ controller: this.controller }) .margin(10) .border({ width: 1, color: Color.Black, style: BorderStyle.Solid }) .width(640).height(120).align(Alignment.TopStart) .backgroundColor('#FFFFFFFF') .enterKeyType(this.enterTypes[this.switchIndex]) // 通过switchIndex切换当前组件的enterKeyType .onSubmit((enterKey: EnterKeyType, event: SubmitEvent) => { console.info('onSubmit', enterKey); // 通过enterKeyType和ctrl状态判断当前执行发送还是换行逻辑 if ((enterKey === EnterKeyType.Send && !this.ctrlFlag) || (enterKey === EnterKeyType.NEW_LINE && this.ctrlFlag)) { this.submit(); // 执行发送逻辑 } else if (enterKey === EnterKeyType.Send && this.ctrlFlag){ this.controller.addTextSpan('\n'); // 执行换行逻辑 } event.keepEditableState(); // 输入框保持输入态,保证连续连续性 }) .onKeyEvent((event: KeyEvent) => { if (event.getModifierKeyState) { // 每次keyEvent更新ctrl按键状态 this.ctrlFlag = event.getModifierKeyState(['Ctrl']); } }) } Row({ space: 20 }) { Button('Submit').onClick(() => { this.submit(); }) Select([{ value: 'Enter' }, { value: 'Ctrl + Enter' }]) .selected(this.switchIndex) // 使用switchIndex作为当前选择模式的索引 .font({ size: 16, weight: 500 }) .value(this.switchText) .fontColor('#182431') .selectedOptionFont({ size: 16, weight: 400 }) .optionFont({ size: 16, weight: 400 }) .menuAlign(MenuAlignType.START, { dx: 0, dy: 0 }) .optionWidth(200) .optionHeight(300) .onSelect((index: number, text?: string | undefined) => { console.info('Select:' + index) this.switchIndex = index; this.switchText = text as string; }) } } .height('100%') .width('100%') } submit() { console.info('submit message.'); let spans = this.controller.getSpans(); for (let spansElement of spans) { if (spansElement) { this.text += (spansElement as RichEditorTextSpanResult).value; } } if (!this.text.endsWith('\n')) { this.text += '\n' } this.controller.deleteSpans(); } }
二、通过快捷键后台唤起场景适配
场景介绍
IM类应用在后台运行时,通常需要使用快捷键拉起程序主界面,由于当前程序不在前台,无法通过键盘事件监听和组件快捷键实现此功能,需要使用系统提供的组合按键订阅模块实现此功能。
导入模块
import { inputConsumer } from '@kit.InputKit';
接口说明
接口名称 |
描述 |
---|---|
getAllSystemHotkeys(): Promise<Array<hotkeyoptions>></hotkeyoptions> |
获取系统所有快捷键,使用Promise异步回调。 |
on(type: 'hotkeyChange', hotkeyOptions: HotkeyOptions, callback: Callback<hotkeyoptions>): void</hotkeyoptions> |
订阅全局组合按键,当满足条件的组合按键输入事件发生时,使用Callback异步方式上报组合按键数据。 |
off(type: 'hotkeyChange', hotkeyOptions: HotkeyOptions, callback?: Callback<hotkeyoptions>): void</hotkeyoptions> |
取消订阅全局组合按键。 |
组合按键订阅模块接口如下表所示,接口详细介绍请参考@ohos.multimodalInput.inputConsumer (全局快捷键)。
订阅全局组合按键
开发步骤
1、定义响应后台唤起的组合事件。
2、定义后台唤起的回调事件。
3、调用接口注册组合事件,判断是否注册成功。
// 1. 定义响应后台唤起的组合事件,此处以 Ctrl + Alt + Z 为例 weekUpKeyOptions: inputConsumer.HotkeyOptions = { preKeys: [ KeyCode.KEYCODE_CTRL_LEFT, KeyCode.KEYCODE_ALT_LEFT ], finalKey: KeyCode.KEYCODE_Z, }; // 2. 定义后台唤起的回调事件,拉起当前窗口 weekUpCallback = (keyOptions: inputConsumer.HotkeyOptions) => { this.mainWindow.restore(); } // 3. 注册后台唤起的组合事件 registerWeekUp(): void { try { inputConsumer.on("hotkeyChange", weekUpKeyOptions, this.weekUpCallback); this.weekUpFlag = true; } catch (error) { this.weekUpFlag = false; console.log(`>>>>>>>>>>Subscribe failed, Cause code: ${error.code}, message: ${error.message}`); } }
取消订阅全局组合按键
开发步骤
1、使用定义好的组合事件。
2、调用接口取消注册组合事件。
unregisterWeekUp(): void { try { inputConsumer.off("hotkeyChange", weekUpKeyOptions); this.weekUpFlag = false; } catch (error) { this.weekUpFlag = true; console.error(`>>>>>>>>>>Execute failed, error: ${JSON.stringify(error, [`code`, `message`])}`); } }
三、快捷键截图场景适配
IM类应用在运行时,通常需要使用快捷键截图,由于当前程序不在前台,无法通过键盘事件监听和组件快捷键实现此功能,需要使用系统提供的组合按键订阅模块实现此功能,实现方式与后台唤起相同,将后台唤起回调逻辑换成截图逻辑即可。
参考示例
import { inputConsumer } from '@kit.InputKit'; import { KeyCode } from '@kit.InputKit'; import { screenshot, window } from '@kit.ArkUI'; import { BusinessError } from '@kit.BasicServicesKit'; // 1. 定义响应后台唤起的组合事件,此处以 Ctrl + Alt + Z 为例 const weekUpKeyOptions: inputConsumer.HotkeyOptions = { preKeys: [ KeyCode.KEYCODE_CTRL_LEFT, KeyCode.KEYCODE_ALT_LEFT ], finalKey: KeyCode.KEYCODE_Z, }; const screenshotKeyOptions: inputConsumer.HotkeyOptions = { preKeys: [ KeyCode.KEYCODE_CTRL_LEFT, KeyCode.KEYCODE_ALT_LEFT ], finalKey: KeyCode.KEYCODE_A, }; @Entry @Component struct Index { @State text: string = ''; @State enterTypes: Array<EnterKeyType> = [EnterKeyType.Send, EnterKeyType.NEW_LINE]; @State switchText: string = 'Enter'; @State switchIndex: number = 0; @State ctrlFlag: boolean = false; // 维护ctrl按键状态 @State weekUpFlag: boolean = false; @State screenshotFlag: boolean = false; private controller: RichEditorController = new RichEditorController(); private mainWindow?: window.Window; aboutToAppear() { this.registerWeekUp(); this.registerScreenshot(); window.getLastWindow(this.getUIContext().getHostContext()).then((mainWindow: window.Window) => { this.mainWindow = mainWindow; }); } // 2. 定义后台唤起的回调事件,拉起当前窗口 weekUpCallback = (keyOptions: inputConsumer.HotkeyOptions) => { this.mainWindow?.restore(); } // 3. 注册后台唤起的组合事件 registerWeekUp(): void { try { inputConsumer.on("hotkeyChange", weekUpKeyOptions, this.weekUpCallback); this.weekUpFlag = true; } catch (error) { this.weekUpFlag = false; console.log(`>>>>>>>>>>Subscribe failed, Cause code: ${error.code}, message: ${error.message}`); } } unregisterWeekUp(): void { try { inputConsumer.off("hotkeyChange", weekUpKeyOptions); this.weekUpFlag = false; } catch (error) { this.weekUpFlag = true; console.error(`>>>>>>>>>>Execute failed, error: ${JSON.stringify(error, [`code`, `message`])}`); } } screenshotCallback = (keyOptions: inputConsumer.HotkeyOptions) => { try { screenshot.pick().then((pickInfo: screenshot.PickInfo) => { console.log('pick Pixel bytes number: ' + pickInfo.pixelMap.getPixelBytesNumber()); console.log('pick Rect: ' + pickInfo.pickRect); this.controller.addImageSpan(pickInfo.pixelMap); pickInfo.pixelMap.release(); // PixelMap使用完后及时释放内存 }).catch((err: BusinessError) => { console.log('Failed to pick. Code: ' + JSON.stringify(err)); }); } catch (exception) { console.error('Failed to pick Code: ' + JSON.stringify(exception)); }; } registerScreenshot(initFlag?: boolean): void { try { inputConsumer.on("hotkeyChange", screenshotKeyOptions, this.screenshotCallback); this.screenshotFlag = true; } catch (error) { this.screenshotFlag = false; console.log(`>>>>>>>>>>Subscribe failed, Cause code: ${error.code}, message: ${error.message}`); } } unregisterScreenshot(): void { try { inputConsumer.off("hotkeyChange", screenshotKeyOptions); this.screenshotFlag = false; } catch (error) { this.screenshotFlag = true; console.error(`>>>>>>>>>>Execute failed, error: ${JSON.stringify(error, [`code`, `message`])}`); } } build() { Column({ space: 20 }) { Row({ space: 20 }) { Text(this.text) .fontSize(16) .backgroundColor(0xEEEEEE) .margin({ top: 20, right: 20, bottom: 20, left: 20 }) .border({ width: 1, color: Color.Black, style: BorderStyle.Solid }) .width(640).height(360).align(Alignment.TopStart) } .margin({ top: 20}) Row({ space: 20 }) { RichEditor({ controller: this.controller }) .margin(10) .border({ width: 1, color: Color.Black, style: BorderStyle.Solid }) .width(640).height(120).align(Alignment.TopStart) .backgroundColor('#FFFFFFFF') .enterKeyType(this.enterTypes[this.switchIndex]) // 通过switchIndex切换当前组件的enterKeyType .onSubmit((enterKey: EnterKeyType, event: SubmitEvent) => { console.info('onSubmit', enterKey); // 通过enterKeyType和ctrl状态判断当前执行发送还是换行逻辑 if ((enterKey === EnterKeyType.Send && !this.ctrlFlag) || (enterKey === EnterKeyType.NEW_LINE && this.ctrlFlag)) { this.submit(); // 执行发送逻辑 } else if (enterKey === EnterKeyType.Send && this.ctrlFlag){ this.controller.addTextSpan('\n'); // 执行换行逻辑 } event.keepEditableState(); // 输入框保持输入态,保证连续连续性 }) .onKeyEvent((event: KeyEvent) => { if (event.getModifierKeyState) { // 每次keyEvent更新ctrl按键状态 this.ctrlFlag = event.getModifierKeyState(['Ctrl']); } }) } Row({ space: 20 }) { Button('Submit').onClick(() => { this.submit(); }) Select([{ value: 'Enter' }, { value: 'Ctrl + Enter' }]) .selected(this.switchIndex) // 使用switchIndex作为当前选择模式的索引 .font({ size: 16, weight: 500 }) .value(this.switchText) .fontColor('#182431') .selectedOptionFont({ size: 16, weight: 400 }) .optionFont({ size: 16, weight: 400 }) .menuAlign(MenuAlignType.START, { dx: 0, dy: 0 }) .optionWidth(200) .optionHeight(300) .onSelect((index: number, text?: string | undefined) => { console.info('Select:' + index) this.switchIndex = index; this.switchText = text as string; }) } Row({ space: 20 }) { Text('后台唤起快捷键(Ctrl+Alt+Z):') Toggle({ type: ToggleType.Switch, isOn: this.weekUpFlag }) .selectedColor('#FF007DFF') .switchPointColor('#FFFFFFFF') .onChange((isOn: boolean) => { if (isOn) { this.registerWeekUp(); } else { this.unregisterWeekUp(); } }) Text('截图快捷键(Ctrl+Alt+A):') Toggle({ type: ToggleType.Switch, isOn: this.screenshotFlag }) .selectedColor('#FF007DFF') .switchPointColor('#FFFFFFFF') .onChange((isOn: boolean) => { if (isOn) { this.registerScreenshot(); } else { this.unregisterScreenshot(); } }) } } .height('100%') .width('100%') } submit() { console.info('submit message.'); let spans = this.controller.getSpans(); for (let spansElement of spans) { if (spansElement) { this.text += (spansElement as RichEditorTextSpanResult).value; } } if (!this.text.endsWith('\n')) { this.text += '\n' } this.controller.deleteSpans(); } }
更多推荐
所有评论(0)