HarmonyOS 6.0 输入法与键盘避让 — 聊天页面底部输入栏被键盘挡住的绝望

对应Demo: KeyboardDemo | 难度: 高级 | 关键词: setKeyboardAvoidMode, KeyboardAvoidMode, keyboardHeightChange, 软键盘避让

我做聊天页面的时候,差点把键盘给砸了------不是物理键盘,是软键盘。需求很简单:底部一个输入栏,点击输入框弹出软键盘,输入栏跟着键盘上去,聊天记录不被遮挡。听起来多简单的事儿?我在Android上用android:windowSoftInputMode="adjustResize"一行配置就搞定了。结果到HarmonyOS这边,我从下午搞到凌晨两点,底部输入栏要么被键盘完全盖住,要么整个页面被顶上去连标题栏都看不见了,要么输入栏上去了但聊天记录底部被截断,要么页面上下跳动跟蹦迪似的。

最离谱的是,我搜了一堆资料,有的说用window.setKeyboardAvoidMode,有的说手动监听键盘高度做偏移,还有的说用expandSafeArea。我把这些方案全试了一遍,每个都有bug。后来才搞清楚------HarmonyOS的键盘避让机制经历过好几个版本的变更,网上很多资料都是旧版本的做法,在新版本上根本不work。比如那个window.setKeyboardAvoidMode,在当前版本根本不存在于window对象上------这个方法是通过UIContext调用的!

还有一个让人崩溃的坑------keyboardHeightChange回调返回的键盘高度单位是PX,不是VP!我直接拿这个值当VP用了,偏移量差了好几倍,输入栏要么飞出屏幕,要么只上移了一点点还是被挡住。排查了两个小时才发现是单位搞错了。

今天这篇,我把键盘避让从原理到实战全部讲清楚,帮你少走我走过的弯路。


键盘避让的三种模式

HarmonyOS提供了三种键盘避让模式,通过setKeyboardAvoidMode设置。不同模式的行为完全不同,选错了模式后面怎么调都不对。

模式 数值 行为 适用场景
DEFAULT 0 页面上移(整体推上去),底部内容可能超出屏幕 普通表单页面
RESIZE 1 页面高度压缩,布局重新计算 聊天页面、底部有固定输入栏的页面
NONE 4 不做任何避让,键盘直接覆盖内容 有悬浮控件的页面、手动处理避让

默认是DEFAULT模式。如果你的应用什么都没设置,用的就是DEFAULT。

设置避让模式------关键:通过UIContext调用

这里有个巨大的坑------setKeyboardAvoidMode不是通过window对象调用的,而是通过UIContext。我第一次写的时候搜到的资料说用window.setKeyboardAvoidMode,直接在window对象上调用,编译没报错但运行时方法不存在。后来翻官方文档才发现是通过UIContext调用的。

typescript 复制代码
// ❌ 错误:window对象上没有setKeyboardAvoidMode
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
let win = window.getLastWindow(context)
win.setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)  // 运行时报错!方法不存在

// ✅ 正确:通过UIContext设置
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)

我后来在技术社区里看到好几个人问"setKeyboardAvoidMode为什么报错",十有八九都是在window对象上调用的。这个问题太常见了,因为旧版本的文档和教程确实是在window上调用的,后来API改了位置但很多资料没更新。

在页面中设置

最常用的做法是在页面的aboutToAppear里设置避让模式:

typescript 复制代码
@Entry
@Component
struct ChatPage {
  aboutToAppear(): void {
    // 设置键盘避让模式为RESIZE(压缩模式)
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
  }
}

在Ability中全局设置

如果你想所有页面都用同一种避让模式,可以在EntryAbility里设置。但这个比较麻烦,因为UIContext需要在页面加载后才能获取。我更推荐在页面级别设置------不同页面可能需要不同的避让模式。


DEFAULT模式:页面上移

DEFAULT模式的行为是:键盘弹出时,整个页面向上移动,让当前获得焦点的输入框可见。页面的高度不变,只是位置上移了。

这个模式有个明显的问题------底部固定的内容可能被推出屏幕。比如你的页面底部有个固定定位的按钮或者输入栏,键盘弹出来后,整个页面上移,底部元素就跑到屏幕上面去了------输入框是可见了,但操作按钮和输入栏不见了。

复制代码
没有键盘时:
┌──────────────┐
│  标题栏       │
│              │
│  输入框       │
│              │
│  底部按钮     │
└──────────────┘

DEFAULT模式键盘弹出后:
┌──────────────┐
│  标题栏       │ ← 还在,但上方可能有空白
│  输入框       │ ← 可见,页面上移了
│              │
├──────────────┤
│  软键盘       │
└──────────────┘
底部按钮?被推到屏幕上方了!看不见!

DEFAULT模式适合那种不需要底部固定元素的普通表单页面------输入框在页面中间偏上的位置,键盘弹出来后输入框还是可见的,没有底部元素被遮挡的问题。比如登录页面、注册页面,输入框在中间,下方只有个"登录"按钮,键盘弹出后按钮虽然被推走了但用户已经填完信息了。

但聊天页面绝对不能用DEFAULT模式 ------底部输入栏是核心交互元素,被推走就完蛋了。


RESIZE模式:页面压缩------聊天页面的救星

RESIZE模式的行为是:键盘弹出时,页面的可用高度被压缩,布局重新计算。相当于页面高度从屏幕高度变成了屏幕高度 - 键盘高度

这个模式是做聊天页面的最佳选择------页面高度压缩后,底部的输入栏会自然地"浮"在键盘上方,聊天记录区域也会缩小,不会被遮挡。

复制代码
没有键盘时:
┌──────────────┐
│  标题栏       │
│              │
│  聊天记录     │ ← 占满剩余空间
│              │
│  输入栏       │ ← 底部固定
└──────────────┘

RESIZE模式键盘弹出后:
┌──────────────┐
│  标题栏       │ ← 位置不变
│              │
│  聊天记录     │ ← 高度被压缩了
│              │
│  输入栏       │ ← 在键盘上方
├──────────────┤
│  软键盘       │
└──────────────┘

聊天页面的核心布局

RESIZE模式下的聊天页面布局有个关键------用Column+layoutWeight(1)让聊天记录区域自动填充剩余空间。键盘弹出后,页面高度缩小,聊天记录区域跟着缩小,输入栏始终在底部。

typescript 复制代码
@Entry
@Component
struct ChatPage {
  @State messages: string[] = ['你好', '你好呀', '今天天气真好']
  @State inputText: string = ''

  aboutToAppear(): void {
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
  }

  build() {
    Column() {
      // 标题栏
      this.TitleBar()

      // 聊天记录区域------layoutWeight(1)让它自动填充剩余空间
      List({ space: 12 }) {
        ForEach(this.messages, (msg: string, index: number) => {
          ListItem() {
            Text(msg)
              .padding(12)
              .backgroundColor('#e8e8e8')
              .borderRadius(12)
              .maxWidth('70%')
          }
          .width('100%')
          .alignListItem(index % 2 === 0 ? ListItemAlign.Start : ListItemAlign.End)
        }, (msg: string, index: number) => `${index}`)
      }
      .layoutWeight(1)  // 关键:自动填充剩余空间
      .width('100%')

      // 底部输入栏------不用layoutWeight,固定高度
      this.InputBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
  }

  @Builder TitleBar() {
    Row() {
      Image($r('app.media.ic_back'))
        .width(24)
        .height(24)
      Text('聊天')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin({ left: 12 })
    }
    .width('100%')
    .height(56)
    .padding({ left: 16 })
    .backgroundColor(Color.White)
    // expandSafeArea让标题栏不参与键盘避让
    .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
  }

  @Builder InputBar() {
    Row({ space: 8 }) {
      TextInput({ text: this.inputText, placeholder: '输入消息...' })
        .layoutWeight(1)
        .height(40)
        .backgroundColor(Color.White)
        .borderRadius(20)
        .onChange((value: string) => {
          this.inputText = value
        })

      Button('发送')
        .height(40)
        .backgroundColor('#007DFF')
        .fontColor(Color.White)
        .borderRadius(20)
        .onClick(() => {
          if (this.inputText.length > 0) {
            this.messages.push(this.inputText)
            this.inputText = ''
          }
        })
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(Color.White)
  }
}

RESIZE模式的关键点:

  1. 整体用Column布局
  2. 标题栏固定高度,用expandSafeArea不受键盘影响
  3. 聊天记录用layoutWeight(1)自动填充
  4. 输入栏固定高度,在Column最底部
  5. 键盘弹出后页面高度压缩,聊天记录缩小,输入栏上移到键盘上方

expandSafeArea:让标题栏不被影响

RESIZE模式下,如果标题栏不加expandSafeArea,它可能也会被压缩或者位移。加上expandSafeArea后,标题栏的位置和大小不会因为键盘弹出而改变:

typescript 复制代码
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])

参数说明:

  • 第一个参数[SafeAreaType.KEYBOARD] --- 指定对键盘类型的安全区域生效
  • 第二个参数[SafeAreaEdge.TOP] --- 指定顶部边缘

如果不加这个,在RESIZE模式下标题栏可能会跟着页面一起被压缩,高度变小或者位置偏移。我之前没加这个,键盘弹出后标题栏被挤成了一小条,看起来特别丑。


NONE模式:不避让

NONE模式就是完全不避让,键盘直接覆盖在内容上面。页面高度不变,位置不变,键盘就像一层半透明的遮罩盖在上面。

复制代码
NONE模式键盘弹出后:
┌──────────────┐
│  标题栏       │ ← 不动
│  聊天记录     │ ← 底部被键盘遮挡
│  输入栏       │ ← 被键盘完全遮挡!
├──────────────┤
│  软键盘       │ ← 直接覆盖在上面
└──────────────┘

NONE模式一般配合keyboardHeightChange事件手动处理偏移,适合做高度自定义的布局方案。比如你的页面有一个绝对定位的悬浮按钮,你不想让它因为键盘而被推走或压缩,就可以用NONE模式,然后手动计算偏移量。

不过大部分场景不需要NONE模式。RESIZE和DEFAULT就能满足需求。


keyboardHeightChange:监听键盘高度

有时候你需要知道键盘的高度,比如手动调整某些元素的位置、在键盘弹出时自动滚动到底部等。这时候用window.on('keyboardHeightChange')监听:

typescript 复制代码
import { window, display } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'

@Entry
@Component
struct KeyboardDemo {
  @State keyboardHeight: number = 0
  @State inputText: string = ''
  private curWindow: window.Window | undefined = undefined

  aboutToAppear(): void {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext
    window.getLastWindow(context).then((win) => {
      this.curWindow = win
      win.on('keyboardHeightChange', (data: number) => {
        // data是键盘高度,单位是PX!不是VP!
        if (data > 0) {
          // 键盘弹出
          this.keyboardHeight = this.px2vp(data)
          console.info(`键盘高度: ${data}px = ${this.keyboardHeight}vp`)
        } else {
          // 键盘收起
          this.keyboardHeight = 0
        }
      })
    })
  }

  aboutToDisappear(): void {
    // 重要:页面销毁时移除监听,避免内存泄漏
    if (this.curWindow !== undefined) {
      this.curWindow.off('keyboardHeightChange')
    }
  }

  // px转vp的关键方法
  private px2vp(px: number): number {
    let displayInfo = display.getDefaultDisplaySync()
    return px / displayInfo.densityPixels
  }

  build() {
    Column() {
      Text(`键盘高度: ${this.keyboardHeight}vp`)
        .fontSize(16)

      Column() {
        Text('内容区域')
      }
      .layoutWeight(1)
      .width('100%')

      Row() {
        TextInput({ text: this.inputText, placeholder: '输入...' })
          .layoutWeight(1)
          .height(40)
          .onChange((value: string) => {
            this.inputText = value
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(Color.White)
    }
    .width('100%')
    .height('100%')
  }
}

PX vs VP:最容易踩的坑------再说一遍都不嫌多

keyboardHeightChange回调的data参数单位是PX,不是VP!这是无数人踩过的坑------直接拿这个值当VP用,结果偏移量完全不对。

我第一次做的时候,拿键盘高度直接设marginBottom,结果输入栏要么飞出屏幕(偏移太大),要么只上移了一点点还是被挡住(偏移太小)。排查了两个小时,打印了各种数值,最后发现键盘高度是800多(px),但屏幕的vp高度才360多。除以屏幕密度2.0之后才得到正确的vp值400左右。

必须做单位转换:

typescript 复制代码
// ❌ 直接用px值当vp,偏移量不对
win.on('keyboardHeightChange', (data: number) => {
  this.keyboardHeight = data  // 这是px!不是vp!
  // 如果直接用这个值设margin,偏差会是densityPixels倍
})
// 比如键盘高度810px,直接当vp用就偏移810vp
// 但实际应该偏移810/2.25=360vp(假设densityPixels=2.25)

// ✅ 转换成vp
private px2vp(px: number): number {
  let displayInfo = display.getDefaultDisplaySync()
  return px / displayInfo.densityPixels
}

win.on('keyboardHeightChange', (data: number) => {
  this.keyboardHeight = this.px2vp(data)  // 正确:转成vp
})

display.getDefaultDisplaySync().densityPixels是屏幕的像素密度。用px值除以densityPixels就得到vp值。这个转换在HarmonyOS中很常见,不只是键盘高度------很多系统API返回的尺寸都是px,需要手动转换。

注意:display需要从@kit.ArkUIimport,不是@kit.DeviceInfoKit

typescript 复制代码
import { display } from '@kit.ArkUI'

一定要在aboutToDisappear中移除监听

如果你在aboutToAppear里注册了keyboardHeightChange监听,一定要在aboutToDisappear里移除,否则页面销毁后回调还在执行,会导致内存泄漏甚至crash。

typescript 复制代码
aboutToDisappear(): void {
  if (this.curWindow !== undefined) {
    this.curWindow.off('keyboardHeightChange')
  }
}

这个问题我之前没注意,导致应用长时间使用后内存占用越来越大,最终OOM。后来加上off之后问题就消失了。养成习惯------有on就必须有off,跟Android的register/unregister一个道理。


expandSafeArea详解

expandSafeArea是个很重要的属性------它可以让指定组件"忽略"安全区域的避让,不跟着页面一起移动或压缩。

最常见的场景:键盘弹出后,你不想让顶部标题栏被推上去或压缩。给标题栏加expandSafeArea

typescript 复制代码
// 标题栏不想被键盘影响
Row() {
  Text('聊天')
}
.width('100%')
.height(56)
.backgroundColor(Color.White)
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])

expandSafeArea参数说明:

  • 第一个参数:安全区域类型数组
    • SafeAreaType.KEYBOARD --- 键盘
    • SafeAreaType.SYSTEM --- 状态栏、导航栏
    • SafeAreaType.CUTOUT --- 刘海屏
  • 第二个参数:边缘方向数组
    • SafeAreaEdge.TOP --- 顶部
    • SafeAreaEdge.BOTTOM --- 底部
    • SafeAreaEdge.START --- 左侧
    • SafeAreaEdge.END --- 右侧

也可以同时指定多个类型和多个边缘:

typescript 复制代码
.expandSafeArea(
  [SafeAreaType.KEYBOARD, SafeAreaType.SYSTEM],
  [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]
)

如果没有传第二个参数,默认所有边缘都生效。

expandSafeArea的方向语义

这个有点反直觉------expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])的意思不是"在顶部展开安全区域",而是"让组件在顶部边缘不避让键盘"。

换句话说:

  • 加了expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])的组件 → 键盘弹出时该组件在顶部不受影响
  • 没加的组件 → 键盘弹出时该组件会参与避让(上移或压缩)

在DEFAULT模式下,expandSafeArea能让标题栏不被推上去:

复制代码
DEFAULT模式 + 标题栏有expandSafeArea:
┌──────────────┐
│  标题栏       │ ← 不动!expandSafeArea生效了
│  输入框       │ ← 可见,页面上移
├──────────────┤
│  软键盘       │
└──────────────┘

DEFAULT模式 + 标题栏没有expandSafeArea:
│  标题栏       │ ← 被推上去了,可能看不见
│  输入框       │ ← 可见
├──────────────┤
│  软键盘       │
└──────────────┘

手动偏移方案(不推荐但了解一下)

如果你不想用系统的避让模式,想完全手动控制,可以把避让模式设为NONE,然后监听键盘高度手动调整布局。这种方案灵活但容易出bug:

typescript 复制代码
import { window, display } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'

@Entry
@Component
struct ManualKeyboardDemo {
  @State keyboardHeight: number = 0
  @State inputText: string = ''
  private curWindow: window.Window | undefined = undefined

  aboutToAppear(): void {
    // 设置为NONE模式,完全手动处理
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.NONE)

    let context = this.getUIContext().getHostContext() as common.UIAbilityContext
    window.getLastWindow(context).then((win) => {
      this.curWindow = win
      win.on('keyboardHeightChange', (data: number) => {
        if (data > 0) {
          let displayInfo = display.getDefaultDisplaySync()
          this.keyboardHeight = data / displayInfo.densityPixels
        } else {
          this.keyboardHeight = 0
        }
      })
    })
  }

  aboutToDisappear(): void {
    if (this.curWindow !== undefined) {
      this.curWindow.off('keyboardHeightChange')
    }
  }

  build() {
    Column() {
      // 内容区域
      Scroll() {
        Column() {
          Text('这是内容区域')
            .fontSize(20)
        }
      }
      .layoutWeight(1)
      .width('100%')

      // 底部输入栏 --- 用marginBottom手动偏移
      Row() {
        TextInput({ text: this.inputText, placeholder: '输入...' })
          .layoutWeight(1)
          .height(40)
          .onChange((value: string) => {
            this.inputText = value
          })
        Button('发送')
          .height(40)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(Color.White)
      .margin({ bottom: this.keyboardHeight })  // 手动偏移
    }
    .width('100%')
    .height('100%')
  }
}

这个方案的问题在于:

  1. px转vp的计算可能不精确------不同设备的densityPixels可能不同,在某些设备上偏移量会有小误差
  2. 需要自己处理动画过渡------否则偏移是瞬间的,体验生硬。系统默认模式有平滑动画
  3. 和系统的避让逻辑冲突------如果忘了设NONE模式,系统避让和手动偏移会叠加,偏移量翻倍
  4. 代码量大,维护成本高------每个页面都要写一遍监听和偏移逻辑
  5. 边界情况多------键盘弹出/收起的时机、输入框切换、屏幕旋转......都需要处理

我建议除非有特殊需求(比如高度自定义的键盘交互),否则不要用这种方案。RESIZE模式能满足90%的场景。


常见场景的最佳实践

场景1:聊天页面(最常见)

复制代码
需求:底部输入栏,键盘弹出后输入栏在键盘上方,聊天记录可滚动

方案:
1. setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
2. Column布局:标题栏 + List(聊天记录, layoutWeight(1)) + 输入栏
3. 标题栏加expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
4. 键盘弹出后监听keyboardHeightChange,自动滚动到底部

额外细节:
- 键盘弹出后要自动滚动到最新消息
- 输入框获得焦点时如果聊天记录很长,可能需要手动scrollToBottom
- 发送消息后也要滚动到底部

场景2:登录/注册表单

复制代码
需求:输入框在页面中部,键盘弹出后输入框可见,底部有"登录"按钮

方案:
1. 保持DEFAULT模式(默认值,不用设置)
2. 系统自动把页面推上去,让输入框可见
3. 如果底部按钮需要始终可见,可以用expandSafeArea
4. 或者把按钮放在输入框上方而不是页面最底部

注意:DEFAULT模式下底部按钮会被推走
如果"登录"按钮必须可见,建议把它紧挨着输入框放
不要放在页面最底部

场景3:搜索页面

复制代码
需求:顶部搜索框,键盘弹出后搜索框不动,搜索结果区域被压缩

方案:
1. setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
2. 标题栏(搜索框)加expandSafeArea不参与压缩
3. 搜索结果区域layoutWeight(1)自动压缩

这种布局跟聊天页面类似,只是"输入框"在顶部

场景4:悬浮按钮 + 输入框

复制代码
需求:页面右下角有悬浮按钮,输入框获取焦点后键盘弹出
悬浮按钮不能被推走

方案:
1. setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
2. 悬浮按钮用Stack+position绝对定位,不受布局影响
3. 悬浮按钮加expandSafeArea不参与避让

悬浮按钮用绝对定位(position)是关键
这样它不参与Column的布局计算,不受RESIZE影响

实战:完整的聊天页面

把上面的所有内容整合成一个实际可用的聊天页面:

typescript 复制代码
import { window, display } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'

interface ChatMessage {
  id: string
  content: string
  isMine: boolean
  time: string
}

@Entry
@Component
struct KeyboardDemo {
  @State messages: ChatMessage[] = [
    { id: '1', content: '你好!最近怎么样?', isMine: false, time: '10:00' },
    { id: '2', content: '还不错,你呢?', isMine: true, time: '10:01' },
    { id: '3', content: '我也挺好的,周末有空一起吃饭吗?', isMine: false, time: '10:02' },
    { id: '4', content: '好啊!去哪吃?', isMine: true, time: '10:03' },
    { id: '5', content: '听说新开了一家火锅店,评价不错', isMine: false, time: '10:04' },
  ]
  @State inputText: string = ''
  @State keyboardHeight: number = 0
  private listScroller: Scroller = new Scroller()
  private curWindow: window.Window | undefined = undefined

  aboutToAppear(): void {
    // 关键:设置RESIZE模式
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)

    // 监听键盘高度(用于自动滚动到底部)
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext
    window.getLastWindow(context).then((win) => {
      this.curWindow = win
      win.on('keyboardHeightChange', (data: number) => {
        if (data > 0) {
          let displayInfo = display.getDefaultDisplaySync()
          this.keyboardHeight = data / displayInfo.densityPixels
          // 键盘弹出后滚动到底部
          setTimeout(() => {
            this.listScroller.scrollEdge(Edge.Bottom)
          }, 100)
        } else {
          this.keyboardHeight = 0
        }
      })
    })
  }

  aboutToDisappear(): void {
    // 移除键盘监听,防止内存泄漏
    if (this.curWindow !== undefined) {
      this.curWindow.off('keyboardHeightChange')
    }
  }

  build() {
    Column() {
      // 标题栏 --- 不参与键盘避让
      Row() {
        Image($r('app.media.ic_back'))
          .width(24)
          .height(24)
          .fillColor(Color.White)
          .onClick(() => {
            // 返回
          })
        Text('小明')
          .fontSize(18)
          .fontColor(Color.White)
          .fontWeight(FontWeight.Medium)
          .margin({ left: 12 })
          .layoutWeight(1)
        Image($r('app.media.ic_more'))
          .width(24)
          .height(24)
          .fillColor(Color.White)
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#007DFF')
      .expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])

      // 聊天记录
      List({ space: 8 }) {
        ForEach(this.messages, (msg: ChatMessage) => {
          ListItem() {
            Row() {
              if (msg.isMine) {
                Blank()
              }
              Column() {
                Text(msg.content)
                  .fontSize(16)
                  .fontColor(msg.isMine ? Color.White : '#333')
                  .padding(10)
                  .backgroundColor(msg.isMine ? '#007DFF' : '#e8e8e8')
                  .borderRadius(msg.isMine ?
                    { topLeft: 12, topRight: 12, bottomLeft: 12, bottomRight: 4 } :
                    { topLeft: 12, topRight: 12, bottomLeft: 4, bottomRight: 12 })
                  .maxWidth('70%')
                Text(msg.time)
                  .fontSize(10)
                  .fontColor('#999')
                  .margin({ top: 4 })
              }
              .alignItems(msg.isMine ? HorizontalAlign.End : HorizontalAlign.Start)
              if (!msg.isMine) {
                Blank()
              }
            }
            .width('100%')
          }
        }, (msg: ChatMessage) => msg.id)
      }
      .layoutWeight(1)  // 关键:自动填充剩余空间
      .width('100%')
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .scrollBar(BarState.Off)
      .scroller(this.listScroller)

      // 底部输入栏
      Row({ space: 8 }) {
        Image($r('app.media.ic_voice'))
          .width(28)
          .height(28)
          .fillColor('#666')

        TextInput({ text: this.inputText, placeholder: '输入消息...' })
          .layoutWeight(1)
          .height(36)
          .backgroundColor('#f0f0f0')
          .borderRadius(18)
          .padding({ left: 16, right: 16 })
          .onChange((value: string) => {
            this.inputText = value
          })

        if (this.inputText.length > 0) {
          Button('发送')
            .height(36)
            .fontSize(14)
            .backgroundColor('#007DFF')
            .fontColor(Color.White)
            .borderRadius(18)
            .padding({ left: 16, right: 16 })
            .onClick(() => {
              this.sendMessage()
            })
        } else {
          Image($r('app.media.ic_emoji'))
            .width(28)
            .height(28)
            .fillColor('#666')
        }

        Image($r('app.media.ic_plus'))
          .width(28)
          .height(28)
          .fillColor('#666')
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .backgroundColor(Color.White)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
  }

  private sendMessage(): void {
    if (this.inputText.length === 0) {
      return
    }
    let now = new Date()
    let timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
    let newMsg: ChatMessage = {
      id: Date.now().toString(),
      content: this.inputText,
      isMine: true,
      time: timeStr,
    }
    this.messages.push(newMsg)
    this.inputText = ''

    // 消息发送后滚动到底部
    setTimeout(() => {
      this.listScroller.scrollEdge(Edge.Bottom)
    }, 50)
  }
}

这个实现的关键点:

  1. RESIZE模式 --- 页面高度自动压缩,输入栏自然浮在键盘上方
  2. expandSafeArea --- 标题栏不参与压缩,保持固定
  3. layoutWeight(1) --- 聊天记录区域自动填充剩余空间
  4. keyboardHeightChange --- 监听键盘弹出,自动滚动到最新消息
  5. aboutToDisappear --- 移除键盘监听,防止内存泄漏
  6. scrollEdge(Edge.Bottom) --- 发送消息后和键盘弹出后都要滚动到底部
  7. setTimeout --- 延迟滚动,等布局稳定后再执行

OFFSET_WITH_CARET和RESIZE_WITH_CARET模式

从较新版本开始,HarmonyOS还提供了两种更精细的避让模式,特别适合有多个输入框的表单页面:

  • OFFSET_WITH_CARET --- 偏移模式,但基于光标位置避让
  • RESIZE_WITH_CARET --- 压缩模式,但基于光标位置避让

这两种模式跟OFFSET/RESIZE的区别在于------普通的OFFSET/RESIZE是基于焦点元素(整个TextInput)来避让的,而WITH_CARET版本是基于光标位置来避让的。在多个输入框之间切换时,WITH_CARET版本会根据光标位置重新计算避让区域,确保光标始终可见。

typescript 复制代码
// 多输入框表单页面推荐用RESIZE_WITH_CARET
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE_WITH_CARET)

比如你有一个很长的表单,有10个输入框,用户从第1个输入框切换到第10个输入框时,普通的RESIZE可能只会让第10个输入框的底部可见,但光标位置可能还在被键盘遮挡的区域。WITH_CARET版本会让光标位置可见,用户体验更好。

不过要注意,这两个模式的可用性取决于你的HarmonyOS SDK版本。低版本可能不支持,用之前查一下API版本号。


自定义键盘

HarmonyOS支持自定义软键盘,通过customKeyboard修饰符给TextInput/TextArea指定自定义键盘面板。这种功能在数字输入、密码键盘、表情面板等场景中很有用:

typescript 复制代码
@State inputValue: string = ''
@State showCustomKeyboard: boolean = false

@Builder CustomKeyboardBuilder() {
  Column() {
    Row() {
      ForEach(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (key: string) => {
        Button(key)
          .width(32)
          .height(40)
          .fontSize(18)
          .margin(2)
          .onClick(() => {
            this.inputValue += key
          })
      }, (key: string) => key)
    }
    .flexWrap(FlexWrap.Wrap)
    .justifyContent(FlexAlign.Center)

    Row() {
      Button('删除')
        .width(70)
        .height(40)
        .margin(2)
        .onClick(() => {
          if (this.inputValue.length > 0) {
            this.inputValue = this.inputValue.substring(0, this.inputValue.length - 1)
          }
        })
      Button('0')
        .width(32)
        .height(40)
        .margin(2)
        .onClick(() => {
          this.inputValue += '0'
        })
      Button('确认')
        .width(70)
        .height(40)
        .margin(2)
        .fontColor('#007DFF')
        .onClick(() => {
          // 关闭自定义键盘
          // 注意:自定义键盘的关闭方式可能因版本不同
        })
    }

  }
  .width('100%')
  .padding(12)
  .backgroundColor(Color.White)
}

TextInput({ text: this.inputValue, placeholder: '输入数字' })
  .type(InputType.Number)
  .customKeyboard(this.CustomKeyboardBuilder)

自定义键盘默认不支持避让功能------它弹出时不会触发系统的键盘避让机制。如果需要自定义键盘也参与避让,可以设置KeyboardOptionssupportAvoidance为true。不过自定义键盘的API在不同版本间变化较大,建议查看最新文档。

还有一个常见的坑------自定义键盘面板跟表情面板切换。很多聊天App的输入栏下面有三个面板:表情面板、更多功能面板、系统键盘。这三个面板互斥,只能同时显示一个。切换时需要先关闭当前面板再打开新面板。这个交互逻辑比较复杂,需要仔细处理。


常见踩坑汇总

坑1:在window对象上调用setKeyboardAvoidMode

  • 应该通过this.getUIContext()调用
  • window对象上没有这个方法
  • 网上很多旧资料写的是window上的,别被骗了

坑2:keyboardHeightChange返回的是PX不是VP

  • 必须除以densityPixels转成VP
  • 直接用PX值做偏移,位置肯定不对
  • 这是我花了两小时才找到的bug原因

坑3:DEFAULT模式底部内容被推走

  • DEFAULT模式整个页面上移,底部固定元素可能看不见
  • 聊天页面必须用RESIZE模式
  • 表单页面如果底部有按钮也要小心

坑4:标题栏被键盘推上去或压缩

  • 给标题栏加expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
  • 让标题栏不参与键盘避让
  • 这是在DEFAULT和RESIZE模式下都要注意的

坑5:忘记移除keyboardHeightChange监听

  • 必须在aboutToDisappearoff
  • 否则内存泄漏,长时间使用后可能OOM
  • 养成习惯:有on就有off

坑6:手动偏移方案和系统避让冲突

  • 手动偏移要配合NONE模式
  • 如果用了DEFAULT/RESIZE又手动偏移,效果会叠加出错
  • 偏移量变成两倍

坑7:键盘弹出后聊天记录不滚动到底部

  • 监听keyboardHeightChange,键盘弹出后scrollEdge(Edge.Bottom)
  • 加个小延时(100ms)等布局稳定后再滚动
  • 发送消息后也要滚动到底部

坑8:自定义键盘不参与避让

  • 自定义键盘默认不避让
  • 需要设置KeyboardOptions.supportAvoidance = true
  • 自定义键盘的避让行为可能因版本不同

坑9:RESIZE模式下布局没有用layoutWeight

  • 不用layoutWeight的话聊天记录区域不会自动压缩
  • 输入栏和聊天记录的布局会乱
  • Column + layoutWeight(1)是关键

坑10:display需要从@kit.ArkUI import

  • 不是从@kit.DeviceInfoKitimport
  • import { display } from '@kit.ArkUI'
  • 从错误的kit import会编译报错

键盘避让这个话题看似简单,实际做起来坑极多。我见过的HarmonyOS应用里,聊天页面的键盘处理做得好的真不多------要么输入栏被盖住,要么标题栏消失,要么页面跳动,要么消息列表底部被截断。核心就一句话:聊天页面用RESIZE模式+expandSafeArea+layoutWeight,大部分问题都能解决。如果还有问题,再考虑用keyboardHeightChange监听做额外处理。

我做了好几个聊天类的应用,每次做键盘避让都有新的感悟。第一次做的时候纯靠试,什么方案都试了一遍,搞得一团糟。第二次做的时候学聪明了,直接用RESIZE+expandSafeArea,10分钟搞定。第三次更顺了,直接把之前封装好的聊天页面模板拿来改改就行。所以如果你是第一次做,别怕踩坑,踩过了下次就会了。

还有一个很多人忽略的细节------键盘弹出时,如果聊天记录很长,虽然输入栏跟着上去了,但最新的消息可能还是被键盘挡住。所以一定要在keyboardHeightChange回调里自动滚动到底部,不然用户发送消息后看不到自己的消息,以为没发出去。


实用要点回顾:

  1. 聊天页面用KeyboardAvoidMode.RESIZE,表单页面用DEFAULT
  2. setKeyboardAvoidMode通过UIContext调用,不是window对象
  3. keyboardHeightChange回调的值是PX,要除以densityPixels转VP
  4. 标题栏加expandSafeArea([SafeAreaType.KEYBOARD])防止被推走
  5. aboutToDisappear里移除键盘监听防止内存泄漏
  6. 键盘弹出后延时滚动到底部,等布局稳定
  7. 优先用系统避让模式,手动偏移方案尽量不用
  8. 聊天记录区域用layoutWeight(1)自动填充
  9. 多输入框表单可以考虑RESIZE_WITH_CARET模式
  10. display@kit.ArkUIimport,不是其他kit
相关推荐
线束线缆组件品替网1 小时前
Amphenol ICC RJE1Y22610C42401线束组件应用解析
网络·数码相机·智能手机·计算机外设·电脑·电视盒子
不羁的木木2 小时前
HarmonyOS APP实战-基于Image Kit的图像处理APP - 第12篇:性能优化与内存管理
图像处理·性能优化·harmonyos
拥抱太阳06162 小时前
HarmonyOS 应用开发《掌上英语》第6篇-资源管理最佳实践多模块资源复用
pytorch·深度学习·harmonyos
2301_768103493 小时前
HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底
harmonyos·arkts·preferences·本地缓存·数据兼容
HONG````3 小时前
ArkTS 并发编程:TaskPool 多线程实战
harmonyos·arkts
不羁的木木3 小时前
HarmonyOS APP实战-基于Image Kit的图像处理APP - 第8篇:图片编码与保存
图像处理·华为·harmonyos
春卷同学3 小时前
HarmonyOS掌上记账APP开发实践第2篇:从零搭建记账应用 — MoneyTrack 工程全景解析![
华为·harmonyos
春卷同学3 小时前
HarmonyOS掌上记账APP开发实践第1篇:HarmonyOS 多模块 HAR 架构最佳实践 — MoneyTrack 多模块拆分之道
华为·架构·harmonyos
youtootech4 小时前
HarmonyOS《柚兔学伴》项目实战10-半模态与弹窗交互
华为·交互·harmonyos