From a32c0491998a0d22591bc659d074088232bc0b8b Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 21 Oct 2026 09:03:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=9C=B0=E7=82=B9):=20=E5=80=99=E9=80=89?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20bindPopup=20=E6=B5=AE=E5=B1=82=EF=BC=8C?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E8=BE=93=E5=85=A5=E6=A1=86=E8=B7=B3=E5=8A=A8?= =?UTF-8?q?=E4=B8=8E=E9=80=89=E5=AE=8C=E5=A4=8D=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧方案把候选做成内联列表,虽放在输入框上方,但: 1. 候选高度变化(搜索中 / 出结果 / 条数变化)会改变表单高度,输入框随之反复移动, 再靠 scrollBy 事后纠正,表现为输入框来回跳好几次; 2. 选中候选后 applyPlace 程序回写 text 会触发 TextInput.onChange, 把 placePicked 重置为 false 并重新发起搜索,导致候选框消失后又冒出来一次。 改为: - 候选用 .bindPopup($$placeOpen, { placement: Top }) 挂在输入框上, 不参与布局,出现与消失完全不移动输入框; - 移除全部 scrollBy 对齐逻辑(keyboardPadVp / ensurePlaceVisible / 键盘监听), 键盘避让交由 KeyboardAvoidMode.RESIZE 的默认光标可见行为; - 用 lastAppliedText 精确识别「程序回写 text 造成的 onChange」,不再发搜索; - runSuggest 期间浮层持续展开(搜索中 → 结果),避免收起再弹造成闪烁; - 抽出 closePlacePopup() 作为统一收起入口。 --- entry/src/main/ets/pages/EventEditPage.ets | 364 +++++++++------------ 1 file changed, 148 insertions(+), 216 deletions(-) diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 9bd8c52..8a6b41b 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -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 { + 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 { @@ -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; // 组件还没上树,放弃本次校正 - } - // 首选: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; - } - 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}`); + @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')) } - }, 60); - }, 60); + .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) + } + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding({ left: 14, right: 14, top: 10, bottom: 10 }) + } + .onClick(() => { + this.applyPlace(it); + }) + }, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`) + } + .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 }) + } + } + .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.placeOpen = false; - this.placeLoading = 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; + } + } }) .id('placeAnchor') // 在地图上选点(系统地图界面,可选可搜可周边) @@ -1614,7 +1547,6 @@ struct EventEditPage { .layoutWeight(1) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) - .id('formScroll') .align(Alignment.Top) } .width('100%')