fix(地点): 候选改为 bindPopup 浮层,根治输入框跳动与选完复现

旧方案把候选做成内联列表,虽放在输入框上方,但:
  1. 候选高度变化(搜索中 / 出结果 / 条数变化)会改变表单高度,输入框随之反复移动,
     再靠 scrollBy 事后纠正,表现为输入框来回跳好几次;
  2. 选中候选后 applyPlace 程序回写 text 会触发 TextInput.onChange,
     把 placePicked 重置为 false 并重新发起搜索,导致候选框消失后又冒出来一次。

改为:
- 候选用 .bindPopup($$placeOpen, { placement: Top }) 挂在输入框上,
  不参与布局,出现与消失完全不移动输入框;
- 移除全部 scrollBy 对齐逻辑(keyboardPadVp / ensurePlaceVisible / 键盘监听),
  键盘避让交由 KeyboardAvoidMode.RESIZE 的默认光标可见行为;
- 用 lastAppliedText 精确识别「程序回写 text 造成的 onChange」,不再发搜索;
- runSuggest 期间浮层持续展开(搜索中 → 结果),避免收起再弹造成闪烁;
- 抽出 closePlacePopup() 作为统一收起入口。
This commit is contained in:
2026-10-21 09:03:31 +08:00
parent 6ab7bcfb76
commit a32c049199
+142 -210
View File
@@ -1,6 +1,6 @@
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router, KeyboardAvoidMode, ComponentUtils, window } from '@kit.ArkUI';
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit';
@@ -37,11 +37,13 @@ struct EventEditPage {
@State location: string = '';
/**
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
* placeItems —— 输入框下方的候选列表(内联展开,**不新增 bindSheet**:
* 本页只能挂一个弹层,见文件末尾注释)
* placeLoading —— 是否正在请求,用于显示"搜索中"
* placeOpen —— 候选列表是否展开
* placeHint —— 地图能力不可用时的提示(不阻塞用户手输地点)
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
*
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
*/
@State placeItems: PlaceItem[] = [];
@State placeLoading: boolean = false;
@@ -50,27 +52,17 @@ struct EventEditPage {
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
private placePicked: boolean = false;
/**
* 地点输入框当前是否聚焦。
* 用途:候选条数变化会改变输入框上方的内容高度 → 输入框在屏幕上会挪位。
* 只要还在聚焦状态(用户正在打字),就每次重新钉一次,保证它始终贴住键盘上沿。
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
* 不是用户打字造成的",从而不重复发起搜索。
*/
private lastAppliedText: string = '';
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
private placeFocused: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/**
* ⭐ 表单滚动控制器。
* 地点是表单第 7 个字段(标题/全天/时间/重复/提醒/日历本之后),
* 键盘弹起后输入框本身已经在屏幕下半部,候选列表更是被键盘压住。
* ArkUI 默认 OFFSET 模式只保证"光标可见",不会为候选列表留空间,
* 所以聚焦后要**主动滚动**,把输入框钉到可视区底边(= 键盘上沿)。
*/
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
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;
@@ -129,63 +121,29 @@ struct EventEditPage {
return this.initPage();
}
/** 页面销毁:清掉地点补全的防抖定时器,并注销键盘监听 */
/** 页面销毁:清掉地点补全的防抖定时器 */
aboutToDisappear(): void {
this.cancelSuggestTimer();
this.releaseKeyboardWatch();
}
/**
* ⭐ 键盘避让:改成 RESIZE 模式。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕,
* 且候选列表展开时不会重新计算可见区 → 列表被键盘压住。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 此时 Scroll 的矩形底边就是真正的键盘上沿,据此把输入框钉住即可。
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
*
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
*/
private async setupKeyboardAvoid(): Promise<void> {
private setupKeyboardAvoid(): void {
try {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
// 记录键盘高度:Scroll 矩形取不到时用它兜底算可视底边
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.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> {
@@ -324,14 +282,7 @@ struct EventEditPage {
this.cancelSuggestTimer();
const q: string = v.trim();
if (q.length < 1) {
this.placeItems = [];
this.placeOpen = false;
this.placeLoading = false;
this.placeHint = '';
// 候选清空后上方内容变矮,输入框会往下掉 —— 重新钉一次
if (this.placeFocused) {
this.ensurePlaceVisible();
}
this.closePlacePopup();
return;
}
this.placeTimer = setTimeout(() => {
@@ -354,18 +305,15 @@ struct EventEditPage {
if (PlaceSuggest.isUnavailable()) {
// 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输)
this.placeHint = '地图服务不可用,可直接手动输入地点';
this.placeOpen = false;
this.placeItems = [];
this.placeOpen = true;
return;
}
// 用户输入时若带空格,PlaceSuggest 内部会 trim
// ⭐ 先把旧候选收起再显示"搜索中":候选区高度变化会让输入框上下跳,
// 收起后由 ensurePlaceVisible() 统一重新钉住。
this.placeOpen = false;
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
this.placeLoading = true;
this.placeHint = '';
if (this.placeFocused) {
this.ensurePlaceVisible();
}
this.placeOpen = true;
try {
const c = this.getUIContext().getHostContext();
const ctx: common.Context | undefined = c;
@@ -379,94 +327,115 @@ struct EventEditPage {
return;
}
this.placeItems = items.slice(0, 6);
this.placeOpen = this.placeItems.length > 0;
// ⭐ 候选条数变化改变了输入框上方的高度 → 重新钉住输入框
if (this.placeFocused || this.placeOpen) {
this.ensurePlaceVisible();
}
if (this.placeItems.length === 0) {
this.placeHint = PlaceSuggest.isUnavailable()
? '地图服务不可用,可直接手动输入地点'
: '没有找到匹配地点,可直接手动输入';
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
this.placeOpen = true;
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点补全异常: ${e.message}`);
this.placeItems = [];
this.placeHint = '地点搜索失败,可直接手动输入';
this.placeOpen = false;
this.placeOpen = true;
} finally {
this.placeLoading = false;
}
}
/**
* ⭐ 把「地点输入框」钉在键盘正上方:滚到「输入框底边 = 可视区底边 − 10vp」,
* 候选列表在它**上方**向上生长,所以输入框不会被候选顶走,用户可以边看边继续打字。
* ⭐ 候选浮层(bindPopup)的内容。
*
* 两个曾经踩过的坑,这里都规避掉:
* ① **不要重复扣键盘高度**。本页已设 KeyboardAvoidMode.RESIZE —— 窗口本身
* 已经压缩到键盘上沿之外,`Scroll` 的矩形底边就是真正可视底边。
* 再减一次 keyboardHeight 会滚过头(输入框被推飞出屏幕)。
* ② **单位必须换算**。`getRectangleById()` 返回 **px**,而 `Scroller.scrollBy()`
* 收 **vp**。直接把 px 当 vp 传,在高密度屏上等于多滚了 3 倍 ——
* 这就是上一版"输入框突然跑到上面去、看不见了"的真正原因。
* 为什么是浮层而不是内联列表:
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
*
* 为什么向上弹(Placement.Top):
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
* 向上弹则整块候选都落在输入框与标题之间的空白区。
*/
private ensurePlaceVisible(): void {
// 双延时:等键盘收缩动画 + 候选列表重新布局都稳定后再量尺寸
setTimeout(() => {
setTimeout(() => {
try {
const ui = this.getUIContext();
// ⚠️ getRectangleById 在 ComponentUtils 上,不在 UIContext 上
const cu: ComponentUtils = ui.getComponentUtils();
const vp2px: number = ui.vp2px(1);
let rowBottomPx: number = 0;
let visibleBottomPx: number = 0;
try {
const row = cu.getRectangleById('placeRow');
rowBottomPx = row.windowOffset.y + row.size.height;
} catch (e) {
return; // 组件还没上树,放弃本次校正
@Builder
placePopup() {
Column({ space: 0 }) {
if (this.placeLoading) {
Row({ space: 8 }) {
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
Text('搜索中…')
.fontSize(13)
.fontColor($r('app.color.text_hint'))
}
// 首选:Scroll 的真实矩形(RESIZE 模式下它已经被裁到键盘上沿)
try {
const sc = cu.getRectangleById('formScroll');
visibleBottomPx = sc.windowOffset.y + sc.size.height;
} catch (e2) {
visibleBottomPx = 0;
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
} else if (this.placeItems.length > 0) {
// 限高 + 内部滚动:候选最多 6 条,一次全铺开会顶到屏幕顶部
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
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)
}
// 兜底:拿不到 Scroll 矩形时用「窗口高 − 键盘高」
if (visibleBottomPx <= 0 && this.keyboardHeight > 0 && this.hostWindow !== undefined) {
visibleBottomPx = this.hostWindow.getWindowProperties().windowRect.height - this.keyboardHeight;
}
if (visibleBottomPx <= 0) {
return;
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
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)`);
.onClick(() => {
this.applyPlace(it);
})
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点对齐失败: ${e.message}`);
.width('100%')
.constraintSize({ maxHeight: 216 })
.scrollBar(BarState.Auto)
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
} else {
Text(this.placeHint !== '' ? this.placeHint : '没有找到匹配地点,可直接手动输入')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
}, 60);
}, 60);
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// 写入完整地址(formatAddress),这样同步到服务器/其他客户端时地址是完整的
this.location = it.locationText;
this.placePicked = true;
this.placeItems = [];
this.placeOpen = false;
this.placeHint = '';
// ⭐ 顺序很关键:先收起浮层,再写文本。
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
this.cancelSuggestTimer();
this.closePlacePopup();
this.placePicked = true;
this.lastAppliedText = it.locationText;
this.location = it.locationText;
}
/** 收起候选浮层(统一入口,保证 placePicked 之外的状态都被清干净) */
private closePlacePopup(): void {
this.placeOpen = false;
this.placeLoading = false;
this.placeItems = [];
this.placeHint = '';
}
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
@@ -1457,68 +1426,9 @@ struct EventEditPage {
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// ⭐ 候选列表放在输入框**上方**:这样候选展开时只需把"输入框"贴到键盘上沿,
// 候选自然向上占据剩余空间,输入框不会被顶走,用户能一边看候选一边继续打字。
if (this.placeLoading) {
Row({ space: 6 }) {
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
Text('搜索中…')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 14, top: 2, bottom: 6 })
} else if (this.placeOpen && this.placeItems.length > 0) {
// ⭐ 用 List 而不是 Column:候选可能 6 条,全展开会占掉大半个屏幕。
// 限高 216vp(约 4 条)后内部可滚,既看得全又不会把输入框挤出屏幕。
List() {
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
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 })
}
.onClick(() => {
this.applyPlace(it);
})
}, (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') })
.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, bottom: 6 })
}
// 地点(输入几个字 → 系统地图服务补全候选)
// ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
// 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
Row({ space: 8 }) {
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
.height(44)
@@ -1530,6 +1440,15 @@ struct EventEditPage {
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange((v: string) => {
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
// 精确比对最后一次程序写入值:命中就不再发起搜索,
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
if (v === this.lastAppliedText) {
this.lastAppliedText = '';
this.location = v;
return;
}
this.lastAppliedText = '';
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
@@ -1540,19 +1459,33 @@ struct EventEditPage {
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
// ⭐ 一聚焦就把输入框钉到键盘上方:键盘弹起是异步的,延时对齐两次
// (320ms 时键盘基本已展开;ensurePlaceVisible 内部还会再等 120ms 稳定布局)
.onFocus(() => {
// 键盘避让交给系统(RESIZE 已保证光标可见);
// 候选是浮层,不需要再滚动页面去给它腾位置
this.placeFocused = true;
setTimeout(() => {
this.ensurePlaceVisible();
}, 320);
})
.onBlur(() => {
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选,避免列表残留在屏幕上
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
this.placeFocused = false;
this.closePlacePopup();
})
.bindPopup($$this.placeOpen, {
builder: (): void => {
this.placePopup()
},
placement: Placement.Top,
popupColor: $r('app.color.card_bg'),
enableArrow: false,
showInSubWindow: false,
focusable: false,
autoCancel: true,
radius: 12,
onStateChange: (e: PopupStateChangeParam) => {
// 用户点浮层外部/floating 自动关闭时,同步状态,避免下次不弹
if (!e.isVisible && this.placeOpen) {
this.placeOpen = false;
this.placeLoading = false;
}
}
})
.id('placeAnchor')
// 在地图上选点(系统地图界面,可选可搜可周边)
@@ -1614,7 +1547,6 @@ struct EventEditPage {
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.id('formScroll')
.align(Alignment.Top)
}
.width('100%')