fix(地点): 修掉 px/vp 单位混用的对齐 bug,输入框稳定钉在键盘上方,候选向上生长
- ensurePlaceVisible: getRectangleById 返回 px,scrollBy 收 vp,必须换算(上一版输入框被推飞的真因) - 去掉 keyboardPadVp 动态底部留白:数学上不需要,只会在手动滚到底时露出大块空白 - 聚焦期间候选条数变化(加载中/有结果/无结果)每次都重新钉一次,避免输入框上下跳 - 提交请求前先收起旧候选,统一由一次对齐收口 - 新增 onBlur:失焦即收起候选,不留残影
This commit is contained in:
@@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user