HarmonyOS 鸿蒙Next中如何实现聊天中的表情面板

HarmonyOS 鸿蒙Next中如何实现聊天中的表情面板 支持 emoji 表情和图片表情

3 回复

预览效果:

cke_74887.gif

使用 Swiper + Grid 实现表情面板

1、定义懒加载DataSource

export abstract class BaseDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = []

  public abstract totalCount(): number

  public abstract getData(index: number): T

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener)
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  notifyDataChangeAll(): void {
    const total = this.totalCount()
    for (let i = 0; i < total; i++) {
      this.notifyDataChange(i)
    }
  }

  notifyDataAdd(index: number, count: number = 1): void {
    this.notifyDatasetChange([{ type: DataOperationType.ADD, index: index, count: count }])
  }

  notifyDataChange(index: number, key?: string): void {
    this.notifyDatasetChange([{ type: DataOperationType.CHANGE, index: index, key: key }])
  }

  notifyDataDelete(index: number, count: number = 1): void {
    this.notifyDatasetChange([{ type: DataOperationType.DELETE, index: index, count: count }])
  }

  notifyDataMove(from: number, to: number): void {
    this.notifyDatasetChange([{ type: DataOperationType.MOVE, index: { from: from, to: to } }])
  }

  notifyDatasetChange(operations: DataOperation[]): void {
    this.listeners.forEach(listener => {
      listener.onDatasetChange(operations);
    })
  }

  notifyDataReload() {
    this.notifyDatasetChange([{ type: DataOperationType.RELOAD }])
  }
}

2、实现emoji表情view

/**
 * @fileName : EmojiView.ets
 * @author : @cxy
 * @date : 2025/12/19
 * @description : emoji 组件
 */
import { BaseDataSource } from "./BaseDataSource";


function emojiList(): string[] {
  return [
    "😎", "😍", "😘", "😚", "🙂", "🤗", "🤔", "😑",
    "🙄", "😏", "😣", "😥", "🤐", "😯", "😪", "😫",
    "😴", "😜", "😝", "🤤", "😳", "🤭", "😂", "😄",
    "😭", "❤️", "🤗", "🌞", "🀄", "💰", "👌", "🤞",
    "🐮", "🍀", "😁", "🤣", "😅", "😆", "😉", "😊",
    "😋", "😓", "😒", "😔", "😕", "🙃", "🤑", "😲",
    "👍", "🙁", "😖", "😤", "😢", "😨", "😩", "😬",
    "😰", "😱", "😵", "😡", "😠", "💪", "🤝", "🤩",
    "🥰", "💝", "🌷", "🌼", "🌻", "🌺", "💯", "🙏",
    "🏆", "🌛", "💣", "🚀", "🎉", "😷", "🤒", "🤕",
    "🤢", "🤧", "😇", "🤠", "🍺", "🌹", "🤡", "🤥",
    "🤓", "😈", "👹", "👺", "💀", "👻", "👽", "🤖",
    "💩", "😾", "😸", "😹", "😻", "😼", "😽", "🙀",
    "😿"
  ]
}

export class EmojiDataSource extends BaseDataSource<string> {
  list: string[] = [];

  public totalCount(): number {
    return this.list.length;
  }

  public getData(index: number): string {
    return this.list[index];
  }

  public initList(data: string[]): void {
    this.list.push(...data);
    this.notifyDataAdd(0, this.list.length)
  }
}

@Component
export default struct EmojiView {
  @Link enableDel: boolean
  onSelect?: (emoji: string) => void
  onDelete?: () => void
  private dataSource: EmojiDataSource = new EmojiDataSource()

  aboutToAppear(): void {
    this.dataSource.initList(emojiList())
  }

  build() {
    Stack() {
      Grid() {
        LazyForEach(this.dataSource, (e: string) => {
          GridItem() {
            Text(e).fontSize(22).textAlign(TextAlign.Center).width('100%').aspectRatio(1)
          }.onClick(() => {
            this.onSelect?.(e)
          })
        })
      }
      .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
      .width('100%')
      .height('100%')
      .cachedCount(16)
      .columnsTemplate('1fr '.repeat(8))
      .maxCount(this.dataSource.totalCount())

      Row() {
        Image($r('app.media.chat_em_del'))
          .width(16)
          .opacity(this.enableDel ? 1 : 0.3)
      }
      .borderRadius(5)
      .backgroundColor('#fff')
      .padding({
        left: 14,
        right: 14,
        top: 12,
        bottom: 12
      })
      .offset({ x: -15, y: -10 })
      .onClick(() => {
        if (this.enableDel) {
          this.onDelete?.()
        }
      })
    }
    .height('100%')
    .width('100%')
    .align(Alignment.BottomEnd)
  }
}

3、实现emotion图片view

/**
 * @fileName : EmotionImage.ets
 * @author : @cxy
 * @date : 2025/12/19
 * @description : 表情图片
 */

import { BaseDataSource } from './BaseDataSource';

export class Emotion {
  name: string = ''
  url: ResourceStr = ''

  constructor(name: string, url: ResourceStr) {
    this.name = name
    this.url = url
  }
}

export class EmotionDataSource extends BaseDataSource<Emotion> {
  list: Emotion[] = [];

  public totalCount(): number {
    return this.list.length;
  }

  public getData(index: number): Emotion {
    return this.list[index];
  }

  public initList(data: Emotion[]): void {
    this.list.push(...data);
    this.notifyDataAdd(0, this.list.length)
  }
}

export const EmotionList: Emotion[] = [
  new Emotion('笑脸', $r('app.media.startIcon')),
  new Emotion('笑哭', $r('app.media.startIcon')),
  new Emotion('捂脸', $r('app.media.startIcon')),
  new Emotion('比心', $r('app.media.startIcon')),
  new Emotion('加油', $r('app.media.startIcon')),
  new Emotion('生气', $r('app.media.startIcon')),
  new Emotion('委屈', $r('app.media.startIcon')),
  new Emotion('害羞', $r('app.media.startIcon')),
  new Emotion('吃瓜', $r('app.media.startIcon')),
  new Emotion('狗头', $r('app.media.startIcon')),
  new Emotion('点赞', $r('app.media.startIcon')),
  new Emotion('发财', $r('app.media.startIcon')),
  new Emotion('摆烂', $r('app.media.startIcon'))
];

@Component
export struct EmotionImage {
  onSend?: (e?: Emotion) => void
  private dataSource: EmotionDataSource = new EmotionDataSource()

  async aboutToAppear(): Promise<void> {
    this.dataSource.initList(EmotionList)
  }

  build() {
    Grid() {
      GridItem() {
        Text('所有表情').fontSize(13).fontColor('#666').width('100%')
      }
      .columnStart(0)
      .columnEnd(this.columnCount() - 1)

      LazyForEach(this.dataSource, (e: Emotion) => {
        GridItem() {
          Column({ space: 4 }) {
            Image(e.url).width('100%').aspectRatio(1)
            Text(e.name).fontSize(9).fontColor('#999').maxLines(1)
          }
          .width('100%')
          .alignItems(HorizontalAlign.Center)
        }
        .onClick(() => {
          this.onSend?.(e)
        })
      })
    }
    .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
    .cachedCount(4)
    .width('100%')
    .height('100%')
    .rowsGap(5)
    .columnsGap(5)
    .columnsTemplate('1fr '.repeat(this.columnCount()))
    .padding({
      top: 10,
      left: 10,
      right: 10
    })
    .maxCount(this.dataSource.totalCount() + 1)
  }

  columnCount(): number {
    return 5
  }
}

4、完成emoji+emotion图片结合

/**
 * @fileName : EmotionImageView.ets
 * @author : @cxy
 * @date : 2025/12/19
 * @description : 表情组件
 */

import EmojiView from './EmojiView'
import { Emotion, EmotionImage } from './EmotionImage'

@Component
export struct EmotionComponent {
  @Link enableSend: boolean
  onSelectEmoji?: (value: string) => void
  onDeleteEmoji?: () => void
  onSendEmotion?: (e?: Emotion) => void
  @State selectedIndex: number = 0
  @Prop emotionHeight: number = 310

  build() {
    Column() {
      Row({ space: 8 }) {
        Image($r('app.media.chatbar_emoji'))
          .width(34)
          .height(34)
          .borderRadius(10)
          .backgroundColor(this.selectedIndex === 1 ? '#fff' : '#eee')
          .padding(6)
          .onClick(() => {
            this.selectedIndex = 0
          })
        Image($r('app.media.startIcon'))
          .width(34)
          .height(34)
          .borderRadius(10)
          .padding(5)
          .backgroundColor(this.selectedIndex === 0 ? '#fff' : '#eee')
          .onClick(() => {
            this.selectedIndex = 1
          })
      }
      .justifyContent(FlexAlign.Start)
      .alignItems(VerticalAlign.Center)
      .width('100%')
      .padding({ left: 15, top: 5, bottom: 5 })
      .backgroundColor('#fff')

      Swiper() {
        EmojiView({
          enableDel: this.enableSend,
          onSelect: this.onSelectEmoji,
          onDelete: this.onDeleteEmoji
        })

        EmotionImage({
          onSend: this.onSendEmotion
        })
      }
      .index(this.selectedIndex)
      .layoutWeight(1)
      .width('100%')
      .indicator(false)
      .loop(false)
      .backgroundColor('#efefef')
      .onChange((index: number) => {
        this.selectedIndex = index
      })
    }
    .alignItems(HorizontalAlign.Start)
    .justifyContent(FlexAlign.Start)
    .width('100%')
    .height(this.emotionHeight)
  }
}

完整的demo

更多关于HarmonyOS 鸿蒙Next中如何实现聊天中的表情面板的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


在HarmonyOS Next中,实现聊天表情面板主要使用ArkUI组件。核心是Panel组件,它作为滑动面板可以承载表情内容。通常将Paneltype属性设置为PanelType.Foldable,并配合ColumnGrid容器来布局表情图标。表情资源建议使用Resource管理,通过Image组件加载显示。可以通过绑定PanelonChange事件来控制面板的展开与收起状态,实现与输入框的联动。

在HarmonyOS Next中实现聊天表情面板,可通过以下核心步骤完成:

1. UI布局设计

  • 使用TabContentTabs组件构建分类切换(如emoji、收藏表情、图片表情)。
  • 表情区建议采用GridFlow布局,配合Scroll实现滚动浏览。
  • 底部常驻栏可使用RowFlex布局放置发送按钮等操作项。

2. 表情资源管理

  • Emoji表情:直接使用Unicode字符或系统字体渲染,可通过Text组件显示。
  • 图片表情:将图片资源放入resources/base/media/目录,通过Image组件加载,建议使用PixelMap处理动态图。
  • 资源索引建议使用JSON配置文件管理,便于扩展。

3. 交互与事件处理

  • 表情点击事件通过组件onClick回调实现,返回对应表情的标识符(如Unicode码或图片路径)。
  • 支持长按预览(弹窗显示放大表情)和拖拽发送(通过Gesture组件的拖拽事件实现)。
  • 集成输入框联动:选中表情后,通过TextInputinsert方法插入表情标识符或占位符。

4. 关键代码示例

// 表情面板容器
@Builder
EmojiPanel() {
  Tabs() {
    TabContent() {
      Grid() {
        ForEach(this.emojiList, (item: string) => {
          GridItem() {
            Text(item) // emoji字符
              .onClick(() => {
                this.insertEmoji(item);
              })
          }
        })
      }
    }
    .tabBar('Emoji')

    TabContent() {
      Flow() {
        ForEach(this.imageList, (item: Resource) => {
          FlowItem() {
            Image(item)
              .onClick(() => {
                this.insertImage(item);
              })
          }
        })
      }
    }
    .tabBar('图片')
  }
}

5. 性能优化

  • 表情较多时采用懒加载(LazyForEach)避免内存压力。
  • 图片表情使用Image的缓存机制或预加载。
  • 频繁操作时通过状态管理(如@State)局部更新UI。

6. 扩展功能建议

  • 添加表情搜索栏(Search组件配合过滤逻辑)。
  • 支持自定义表情上传(通过Picker选择图片,使用媒体库接口保存)。
  • 集成动效:面板弹出/收起时添加transition动画提升体验。

注意事项

  • 表情标识符需与服务端约定格式(如[emoji]U+1F600[/emoji])。
  • 图片表情建议限制单张大小,避免传输负载。
  • 多设备适配时,通过mediaQuery或栅格系统调整面板尺寸。

以上方案可快速构建高可用的表情面板,兼顾性能与扩展性。

回到顶部