fix(地点): 修掉 px/vp 单位混用的对齐 bug,输入框稳定钉在键盘上方,候选向上生长

- ensurePlaceVisible: getRectangleById 返回 px,scrollBy 收 vp,必须换算(上一版输入框被推飞的真因)
- 去掉 keyboardPadVp 动态底部留白:数学上不需要,只会在手动滚到底时露出大块空白
- 聚焦期间候选条数变化(加载中/有结果/无结果)每次都重新钉一次,避免输入框上下跳
- 提交请求前先收起旧候选,统一由一次对齐收口
- 新增 onBlur:失焦即收起候选,不留残影
This commit is contained in:
2026-10-21 08:52:13 +08:00
parent 8e0c08ccfb
commit 6ab7bcfb76
+121 -95
View File
@@ -49,6 +49,12 @@ struct EventEditPage {
@State placeHint: string = '';
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
private placePicked: boolean = false;
/**
* 地点输入框当前是否聚焦。
* 用途:候选条数变化会改变输入框上方的内容高度 → 输入框在屏幕上会挪位。
* 只要还在聚焦状态(用户正在打字),就每次重新钉一次,保证它始终贴住键盘上沿。
*/
private placeFocused: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/**
@@ -56,7 +62,7 @@ struct EventEditPage {
* 地点是表单第 7 个字段(标题/全天/时间/重复/提醒/日历本之后),
* 键盘弹起后输入框本身已经在屏幕下半部,候选列表更是被键盘压住。
* ArkUI 默认 OFFSET 模式只保证"光标可见",不会为候选列表留空间,
* 所以必须在候选展开后**主动滚动**,把「输入框 + 候选列表」整体推进可视区。
* 所以聚焦后要**主动滚动**,把输入框钉到可视区底边(= 键盘上沿)。
*/
private formScroller: Scroller = new Scroller();
/** 键盘高度(px),0 = 键盘未弹起。由 window 的 TYPE_KEYBOARD 避让区取得 */
@@ -134,7 +140,7 @@ struct EventEditPage {
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕,
* 且候选列表展开时不会重新计算可见区 → 列表被键盘压住。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 配合下面的主动滚动,候选列表就能稳定落在可视区内。
* 此时 Scroll 的矩形底边就是真正的键盘上沿,据此把输入框钉住即可。
*/
private async setupKeyboardAvoid(): Promise<void> {
try {
@@ -143,7 +149,7 @@ struct EventEditPage {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
// 记录键盘高度:候选列表展开后据此计算需要滚动多少
// 记录键盘高度:Scroll 矩形取不到时用它兜底算可视底边
try {
const c = this.getUIContext().getHostContext();
if (c === undefined) {
@@ -157,8 +163,8 @@ struct EventEditPage {
return;
}
this.keyboardHeight = opt.area.visible ? opt.area.bottomRect.height : 0;
// 键盘弹起时若候选已展开,重新贴到可视区内
if (this.keyboardHeight > 0 && this.placeOpen) {
// 键盘弹起/收起后重新对齐一次(收起时不滚,保持用户当前浏览位置)
if (this.keyboardHeight > 0) {
this.ensurePlaceVisible();
}
};
@@ -320,7 +326,12 @@ struct EventEditPage {
if (q.length < 1) {
this.placeItems = [];
this.placeOpen = false;
this.placeLoading = false;
this.placeHint = '';
// 候选清空后上方内容变矮,输入框会往下掉 —— 重新钉一次
if (this.placeFocused) {
this.ensurePlaceVisible();
}
return;
}
this.placeTimer = setTimeout(() => {
@@ -347,8 +358,14 @@ struct EventEditPage {
return;
}
// 用户输入时若带空格,PlaceSuggest 内部会 trim
// ⭐ 先把旧候选收起再显示"搜索中":候选区高度变化会让输入框上下跳,
// 收起后由 ensurePlaceVisible() 统一重新钉住。
this.placeOpen = false;
this.placeLoading = true;
this.placeHint = '';
if (this.placeFocused) {
this.ensurePlaceVisible();
}
try {
const c = this.getUIContext().getHostContext();
const ctx: common.Context | undefined = c;
@@ -363,8 +380,8 @@ struct EventEditPage {
}
this.placeItems = items.slice(0, 6);
this.placeOpen = this.placeItems.length > 0;
// ⭐ 候选展开后立刻把它推进键盘上方的可视区(否则列表被键盘压住)
if (this.placeOpen) {
// ⭐ 候选条数变化改变了输入框上方的高度 → 重新钉住输入框
if (this.placeFocused || this.placeOpen) {
this.ensurePlaceVisible();
}
if (this.placeItems.length === 0) {
@@ -383,68 +400,59 @@ struct EventEditPage {
}
/**
* ⭐ 保证「地点输入框 + 候选列表」整块都在键盘上方的可视区内。
* ⭐ 把「地点输入框」钉在键盘正上方:滚到「输入框底边 = 可视区底边 − 10vp」,
* 候选列表在它**上方**向上生长,所以输入框不会被候选顶走,用户可以边看边继续打字。
*
* 为什么不直接 scrollEdge(Bottom):那会把整页滚到底,地点字段反而可能被顶出屏幕顶部。
* 这里用**实测坐标**算:候选列表底边距可视区底边还差多少,就滚多少。
* 必须等布局完成后测量(候选刚 insert 进 ForEach 时还没布局),故用两帧延时。
* 两个曾经踩过的坑,这里都规避掉:
* ① **不要重复扣键盘高度**。本页已设 KeyboardAvoidMode.RESIZE —— 窗口本身
* 已经压缩到键盘上沿之外,`Scroll` 的矩形底边就是真正可视底边。
* 再减一次 keyboardHeight 会滚过头(输入框被推飞出屏幕)。
* ② **单位必须换算**。`getRectangleById()` 返回 **px**,而 `Scroller.scrollBy()`
* 收 **vp**。直接把 px 当 vp 传,在高密度屏上等于多滚了 3 倍 ——
* 这就是上一版"输入框突然跑到上面去、看不见了"的真正原因。
*/
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;
const vp2px: number = ui.vp2px(1);
let rowBottomPx: number = 0;
let visibleBottomPx: number = 0;
try {
const anchor = cu.getRectangleById('placeAnchor');
rowTop = anchor.windowOffset.y;
rowH = anchor.size.height;
const row = cu.getRectangleById('placeRow');
rowBottomPx = row.windowOffset.y + row.size.height;
} catch (e) {
// 组件还没上树(极少数时序),放弃本次校正
return; // 组件还没上树,放弃本次校正
}
// 首选:Scroll 的真实矩形(RESIZE 模式下它已经被裁到键盘上沿)
try {
const sc = cu.getRectangleById('formScroll');
visibleBottomPx = sc.windowOffset.y + sc.size.height;
} catch (e2) {
visibleBottomPx = 0;
}
// 兜底:拿不到 Scroll 矩形时用「窗口高 − 键盘高」
if (visibleBottomPx <= 0 && this.keyboardHeight > 0 && this.hostWindow !== undefined) {
visibleBottomPx = this.hostWindow.getWindowProperties().windowRect.height - this.keyboardHeight;
}
if (visibleBottomPx <= 0) {
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)})`);
const gapPx: number = vp2px * 10;
const deltaPx: number = rowBottomPx - (visibleBottomPx - gapPx);
// ±6vp 内视为已对齐,避免每次按键都抖一下
if (Math.abs(deltaPx) > vp2px * 6) {
// ⚠️ px → vp 换算,见上面坑②
this.formScroller.scrollBy(0, deltaPx / vp2px);
LogUtil.write(`地点对齐:滚 ${Math.round(deltaPx / vp2px)}vp(行底 ${Math.round(rowBottomPx / vp2px)}vp / 可视底 ${Math.round(visibleBottomPx / vp2px)}vp)`);
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点候选定位失败: ${e.message}`);
LogUtil.write(`地点对齐失败: ${e.message}`);
}
}, 60);
}, 60);
@@ -1449,45 +1457,8 @@ struct EventEditPage {
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 地点(输入几个字 → 系统地图服务补全候选)
Row({ space: 8 }) {
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
.height(44)
.layoutWeight(1)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => {
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
this.scheduleSuggest(v);
})
.onSubmit(() => {
// 回车:立刻搜一次(不等防抖)
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
.id('placeAnchor')
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.padding({ left: 12, right: 12 })
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => {
this.pickOnMap();
})
}
.width('100%')
// 候选列表(内联展开;本页只能有一个 bindSheet,故不另开弹层)
// ⭐ 候选列表放在输入框**上方**:这样候选展开时只需把"输入框"贴到键盘上沿,
// 候选自然向上占据剩余空间,输入框不会被顶走,用户能一边看候选一边继续打字。
if (this.placeLoading) {
Row({ space: 6 }) {
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
@@ -1496,10 +1467,10 @@ struct EventEditPage {
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 14, top: 6, bottom: 2 })
.padding({ left: 14, top: 2, bottom: 6 })
} else if (this.placeOpen && this.placeItems.length > 0) {
// ⭐ 用 List 而不是 Column:候选可能 6 条,全展开会占掉大半个屏幕。
// 限高 216vp(约 4 条)后内部可滚,既看得全又不会把表单挤没。
// 限高 216vp(约 4 条)后内部可滚,既看得全又不会把输入框挤出屏幕。
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
ListItem() {
@@ -1537,15 +1508,69 @@ struct EventEditPage {
.padding({ top: 4, bottom: 4 })
.scrollBar(BarState.Auto)
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
.margin({ bottom: 8 })
.id('placeList')
} else if (this.placeHint !== '') {
Text(this.placeHint)
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
.padding({ left: 14, top: 6 })
.padding({ left: 14, bottom: 6 })
}
// 地点(输入几个字 → 系统地图服务补全候选)
Row({ space: 8 }) {
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
.height(44)
.layoutWeight(1)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => {
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
this.scheduleSuggest(v);
})
.onSubmit(() => {
// 回车:立刻搜一次(不等防抖)
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
// ⭐ 一聚焦就把输入框钉到键盘上方:键盘弹起是异步的,延时对齐两次
// (320ms 时键盘基本已展开;ensurePlaceVisible 内部还会再等 120ms 稳定布局)
.onFocus(() => {
this.placeFocused = true;
setTimeout(() => {
this.ensurePlaceVisible();
}, 320);
})
.onBlur(() => {
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选,避免列表残留在屏幕上
this.placeFocused = false;
this.placeOpen = false;
this.placeLoading = false;
})
.id('placeAnchor')
// 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.padding({ left: 12, right: 12 })
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => {
this.pickOnMap();
})
}
.width('100%')
.id('placeRow')
// 描述
TextArea({ text: this.description, placeholder: '备注(可选)' })
.height(90)
@@ -1589,6 +1614,7 @@ struct EventEditPage {
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.id('formScroll')
.align(Alignment.Top)
}
.width('100%')