修复:地点候选列表被软键盘挡住

现象:地点输入框在表单第 7 位(标题/全天/时间/重复/提醒/日历本之后),
键盘弹起后候选列表正好落在键盘后面,看不到也点不到。

根因:ArkUI 默认 KeyboardAvoidMode.OFFSET 只保证"光标可见",
它不会为输入框下方新插入的候选列表预留空间;页面也不主动滚动。

修法(三层):
1) setKeyboardAvoidMode(RESIZE):把页面高度压到键盘上沿,
   Scroll 可视区随之变矮(OFFSET 是整页上移,会把表单底部顶出屏幕)。
2) 主动滚动 ensurePlaceVisible():用 getComponentUtils().getRectangleById()
   实测「输入框行顶边 + 行高 + 间距 + 候选列表高度」得到整块底边,
   与「窗口高度 - 键盘高度」比较,差多少就 scrollBy 多少。
   不直接 scrollEdge(Bottom):那会把地点字段顶出屏幕顶部。
3) 兜底:avoidAreaChange 可能还没回调(先弹键盘、后展开候选),
   此时按窗口高度 42% 保守估算键盘高度 —— 多滚一点只是页面上移,无害。

顺带:
- 候选列表 Column → List + constraintSize(maxHeight: 216),
  6 条候选内部可滚,不再占掉大半个屏幕;24px 行高分隔线改用 List.divider。
- 上限 8 条 → 6 条。

⚠️ 踩到的 API 坑(本 SDK):
- KeyboardAvoidMode 必须从 @kit.ArkUI 导入(不是全局);
- UIAbilityContext **没有** window 属性,要用 window.getLastWindow(ctx);
- getRectangleById 在 ui.getComponentUtils() 上,**不在** UIContext 上;
- avoidAreaChange 回调参数是 AvoidAreaOptions(含 type 字段),
  不是老的 AvoidArea,需先判 type === TYPE_KEYBOARD。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 真机。
This commit is contained in:
2026-10-21 08:26:16 +08:00
parent 8be539d8a3
commit 8e0c08ccfb
+171 -23
View File
@@ -1,6 +1,6 @@
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router } from '@kit.ArkUI';
import { curves, router, KeyboardAvoidMode, ComponentUtils, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit';
@@ -51,6 +51,20 @@ struct EventEditPage {
private placePicked: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/**
* ⭐ 表单滚动控制器。
* 地点是表单第 7 个字段(标题/全天/时间/重复/提醒/日历本之后),
* 键盘弹起后输入框本身已经在屏幕下半部,候选列表更是被键盘压住。
* ArkUI 默认 OFFSET 模式只保证"光标可见",不会为候选列表留空间,
* 所以必须在候选展开后**主动滚动**,把「输入框 + 候选列表」整体推进可视区。
*/
private formScroller: Scroller = new Scroller();
/** 键盘高度(px),0 = 键盘未弹起。由 window 的 TYPE_KEYBOARD 避让区取得 */
private keyboardHeight: number = 0;
/** 键盘避让区变化监听器(页面销毁时注销) */
private keyboardWatcher: ((opt: window.AvoidAreaOptions) => void) | undefined = undefined;
/** 本页所属窗口(异步取得,拿到后才能挂监听) */
private hostWindow: window.Window | undefined = undefined;
@State description: string = '';
@State allDay: boolean = false;
@State startMs: number = 0;
@@ -104,12 +118,68 @@ struct EventEditPage {
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
this.setupKeyboardAvoid();
return this.initPage();
}
/** 页面销毁:清掉地点补全的防抖定时器,避免回调打到已销毁的组件上 */
/** 页面销毁:清掉地点补全的防抖定时器,并注销键盘监听 */
aboutToDisappear(): void {
this.cancelSuggestTimer();
this.releaseKeyboardWatch();
}
/**
* ⭐ 键盘避让:改成 RESIZE 模式。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕,
* 且候选列表展开时不会重新计算可见区 → 列表被键盘压住。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 配合下面的主动滚动,候选列表就能稳定落在可视区内。
*/
private async setupKeyboardAvoid(): Promise<void> {
try {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
// 记录键盘高度:候选列表展开后据此计算需要滚动多少
try {
const c = this.getUIContext().getHostContext();
if (c === undefined) {
return;
}
// ⚠️ UIAbilityContext 上没有 window 属性,必须用 window.getLastWindow 取
const win: window.Window = await window.getLastWindow(c);
this.hostWindow = win;
this.keyboardWatcher = (opt: window.AvoidAreaOptions): void => {
if (opt.type !== window.AvoidAreaType.TYPE_KEYBOARD) {
return;
}
this.keyboardHeight = opt.area.visible ? opt.area.bottomRect.height : 0;
// 键盘弹起时若候选已展开,重新贴到可视区内
if (this.keyboardHeight > 0 && this.placeOpen) {
this.ensurePlaceVisible();
}
};
win.on('avoidAreaChange', this.keyboardWatcher);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`监听键盘高度失败: ${e.message}`);
}
}
private releaseKeyboardWatch(): void {
if (this.keyboardWatcher === undefined || this.hostWindow === undefined) {
return;
}
try {
this.hostWindow.off('avoidAreaChange', this.keyboardWatcher);
} catch (err) {
// 页面已在销毁,注销失败无所谓
}
this.keyboardWatcher = undefined;
this.hostWindow = undefined;
}
private async initPage(): Promise<void> {
@@ -291,8 +361,12 @@ struct EventEditPage {
if (q !== this.location.trim()) {
return;
}
this.placeItems = items.slice(0, 8);
this.placeItems = items.slice(0, 6);
this.placeOpen = this.placeItems.length > 0;
// ⭐ 候选展开后立刻把它推进键盘上方的可视区(否则列表被键盘压住)
if (this.placeOpen) {
this.ensurePlaceVisible();
}
if (this.placeItems.length === 0) {
this.placeHint = PlaceSuggest.isUnavailable()
? '地图服务不可用,可直接手动输入地点'
@@ -308,6 +382,74 @@ struct EventEditPage {
}
}
/**
* ⭐ 保证「地点输入框 + 候选列表」整块都在键盘上方的可视区内。
*
* 为什么不直接 scrollEdge(Bottom):那会把整页滚到底,地点字段反而可能被顶出屏幕顶部。
* 这里用**实测坐标**算:候选列表底边距可视区底边还差多少,就滚多少。
* 必须等布局完成后测量(候选刚 insert 进 ForEach 时还没布局),故用两帧延时。
*/
private ensurePlaceVisible(): void {
// 第一帧:等候选列表插入完成;第二帧:等它完成布局,才能测到真实高度
setTimeout(() => {
setTimeout(() => {
try {
const ui = this.getUIContext();
// ⚠️ getRectangleById 在 ComponentUtils 上,不在 UIContext 上
const cu: ComponentUtils = ui.getComponentUtils();
// anchor 是「输入框所在的一整行」(Row),拿它的位置与高度
let rowTop: number = 0;
let rowH: number = 44;
try {
const anchor = cu.getRectangleById('placeAnchor');
rowTop = anchor.windowOffset.y;
rowH = anchor.size.height;
} catch (e) {
// 组件还没上树(极少数时序),放弃本次校正
return;
}
// 候选列表实测高度优先;测不到时按每行约 54vp 估算
let listH: number = 0;
try {
listH = cu.getRectangleById('placeList').size.height;
} catch (e) {
listH = 0;
}
if (listH <= 0) {
// 测不到时按每行约 54vp 估算,且不超过 List 的 maxHeight(216)
listH = Math.min(this.placeItems.length * 54, 216);
}
// 可视区底边 = 窗口高度 - 键盘高度(两者都是 px,统一用 vp 计算)
const vp2px: number = ui.vp2px(1);
let winVp: number = 800;
if (this.hostWindow !== undefined) {
winVp = this.hostWindow.getWindowProperties().windowRect.height / vp2px;
}
// ⚠️ 兜底:avoidAreaChange 可能还没回调(先弹键盘、后展开候选),
// 此时 keyboardHeight 仍是 0。按屏幕 42% 保守估算,
// "多滚一点"只是页面上移、无害;"完全不算"就会被键盘挡住,那才是问题。
const kbdVp: number = this.keyboardHeight > 0
? this.keyboardHeight / vp2px
: winVp * 0.42;
// 底部留白,避免候选紧贴键盘顶边
const visibleBottom: number = winVp - kbdVp - 12;
// 整块底边 = 行顶边 + 行高 + 间距(14) + 列表高
const blockBottom: number = rowTop + rowH + 14 + listH;
if (blockBottom > visibleBottom) {
const delta: number = blockBottom - visibleBottom;
this.formScroller.scrollBy(0, delta);
LogUtil.write(`地点候选上滚 ${Math.round(delta)}vp(底边 ${Math.round(blockBottom)} > 可视 ${Math.round(visibleBottom)})`);
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点候选定位失败: ${e.message}`);
}
}, 60);
}, 60);
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// 写入完整地址(formatAddress),这样同步到服务器/其他客户端时地址是完整的
@@ -1090,7 +1232,7 @@ struct EventEditPage {
.width('100%')
.padding({ left: 16, right: 16 })
Scroll() {
Scroll(this.formScroller) {
Column({ space: 14 }) {
// 标题
TextInput({ text: this.title, placeholder: '标题' })
@@ -1329,6 +1471,7 @@ struct EventEditPage {
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
.id('placeAnchor')
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
@@ -1355,41 +1498,46 @@ struct EventEditPage {
.width('100%')
.padding({ left: 14, top: 6, bottom: 2 })
} else if (this.placeOpen && this.placeItems.length > 0) {
Column() {
// ⭐ 用 List 而不是 Column:候选可能 6 条,全展开会占掉大半个屏幕。
// 限高 216vp(约 4 条)后内部可滚,既看得全又不会把表单挤没。
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
Column({ space: 2 }) {
Text(it.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
if (it.subtitle !== '') {
Text(it.subtitle)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
ListItem() {
Column({ space: 2 }) {
Text(it.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
if (it.subtitle !== '') {
Text(it.subtitle)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.onClick(() => {
this.applyPlace(it);
})
if (idx < this.placeItems.length - 1) {
Divider().color($r('app.color.shadow_color'))
}
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
}
.width('100%')
.constraintSize({ maxHeight: 216 })
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.padding({ top: 4, bottom: 4 })
.scrollBar(BarState.Auto)
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
.id('placeList')
} else if (this.placeHint !== '') {
Text(this.placeHint)
.fontSize(12)