DEV Community

HarmonyOS
HarmonyOS

Posted on

Building Chat Lists on HarmonyOS: Auto-Scroll with @Watch and Bottom-Stacked Loading

Read the original article:Building Chat Lists on HarmonyOS: Auto-Scroll with @Watch and Bottom-Stacked Loading

Chat List Essentials on HarmonyOS: Auto-Scroll to Bottom with @Watch and Bottom-Stacked Lists

Requirement Description

Implement two common IM chat behaviors:

  1. Always show newest messages at the bottom and auto-scroll when new data arrives.
  2. Load/layout from the bottom and provide a button to jump back to the latest position.

Background Knowledge

  • List: versatile list component used in feeds, chat, invoices, etc.
    Guide

  • @Watch: observe state/prop changes and run a callback on change.
    Guide

  • stackFromEnd: List property to layout from the bottom.
    Reference

  • scrollEdge(Edge.Bottom): move a scroller/list scroller to the bottom edge.
    Reference

Implementation Steps

Scenario 1: Show from bottom when new messages arrive

  • Keep messages in a stateful array.
  • Use @Watch on the message list (as a prop in the chat component) to call scrollEdge(Edge.Bottom) whenever the list changes.
  • Set initialIndex to the last item so the list starts at the bottom on first render.

Scenario 2: Layout from bottom + jump-to-latest button

  • Set .stackFromEnd(true) on the List to layout from the bottom.
  • Keep a ListScroller and bind a button to scrollEdge(Edge.Bottom) to jump back to the newest messages.

Code Snippet

See the full working Index.ets at the end (wearable-friendly UI, HarmonyOS 6 / API 19+).

@Entry
@Component
struct WearableMessenger {
  @State private currentTab: number = 0
  @State private notificationCount: number = 0

  @State private chatMessages: ChatMessage[] = []
  private chatCounter: number = 0

  @State private notifications: Notification[] = [
    { id: '0', title: 'Welcome', body: 'Your messages will appear here', time: '09:00', isRead: false }
  ]
  private notificationCounter: number = 1
  private notificationScroller: ListScroller = new ListScroller()

  @State private quickReplies: string[] = ['👍', '❤️', 'OK', 'Thanks', 'Later', 'On my way']

  aboutToAppear() {
    this.updateNotificationBadge()
  }

  private updateNotificationBadge() {
    this.notificationCount = this.notifications.filter(n => !n.isRead).length
  }

  private addChatMessage(): void {
    const messages = [
      'Hello! How are you?',
      'What time is the meeting?',
      'Coffee break?',
      'Report sent',
      'Free tonight?'
    ]
    const randomMsg = messages[Math.floor(Math.random() * messages.length)]
    this.chatMessages.push({
      id: String(this.chatCounter),
      text: randomMsg,
      time: this.getCurrentTime(),
      isSent: this.chatCounter % 2 === 0,
      sender: this.chatCounter % 2 === 0 ? 'You' : 'John'
    })
    this.chatCounter++
  }

  private addNotification(): void {
    const titles = ['New Message', 'Reminder', 'Task', 'Invitation', 'Alert']
    const bodies = [
      'Meeting in 10 minutes',
      'Don\'t forget to exercise',
      'Project deadline approaching',
      'New follower',
      'Weather updated'
    ]
    this.notifications.push({
      id: String(this.notificationCounter),
      title: titles[Math.floor(Math.random() * titles.length)],
      body: bodies[Math.floor(Math.random() * bodies.length)],
      time: this.getCurrentTime(),
      isRead: false
    })
    this.notificationCounter++
    this.updateNotificationBadge()
  }

  private markAsRead(id: string): void {
    const notification = this.notifications.find(n => n.id === id)
    if (notification) {
      notification.isRead = true
      this.updateNotificationBadge()
    }
  }

  private jumpToLatest(): void {
    this.notificationScroller.scrollEdge(Edge.Bottom)
  }

  private getCurrentTime(): string {
    const now = new Date()
    return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
  }

  build() {
    Column() {
      Row({ space: 4 }) {
        ForEach([
          { icon: '💬', label: 'Chat' },
          { icon: '🔔', label: 'Notify' },
          { icon: '', label: 'Quick' }
        ], (tab: TabInfo, index: number) => {
          Column({ space: 2 }) {
            Text(tab.icon)
              .fontSize(18)
            if (index === 1 && this.notificationCount > 0) {
              Badge({
                count: this.notificationCount,
                position: BadgePosition.RightTop,
                style: { badgeSize: 16, badgeColor: '#FF4444' }
              }) {
                Text('')
              }
              .width(20)
              .height(20)
            }
          }
          .width('30%')
          .height(50)
          .backgroundColor(this.currentTab === index ? '#0A59F7' : '#F1F3F5')
          .borderRadius(8)
          .justifyContent(FlexAlign.Center)
          .onClick(() => this.currentTab = index)
        })
      }
      .width('75%')
      .padding({ top: 8, bottom: 8 })

      if (this.currentTab === 0) {
        this.ChatScenario()
      } else if (this.currentTab === 1) {
        this.NotificationScenario()
      } else {
        this.QuickReplyScenario()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
  }

  @Builder ChatScenario() {
    Column({ space: 8 }) {
      Text('Chat Messages')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('90%')

      ChatAutoScroll({ messages: this.chatMessages })
        .width('95%')
        .layoutWeight(1)
        .backgroundColor('#1A1A1A')
        .borderRadius(12)

      Row({ space: 6 }) {
        Button('➕ Add')
          .fontSize(12)
          .height(40)
          .backgroundColor('#0A59F7')
          .onClick(() => this.addChatMessage())

        Button('🗑️ Clear')
          .fontSize(12)
          .height(40)
          .backgroundColor('#FF4444')
          .onClick(() => this.chatMessages = [])
      }
      .width('95%')
      .padding({ bottom: 8 })
    }
    .width('100%')
    .height('100%')
  }

  @Builder NotificationScenario() {
    Column({ space: 8 }) {
      Row({ space: 4 }) {
        Text('Notifications')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)

        if (this.notificationCount > 0) {
          Text(`${this.notificationCount} new`)
            .fontSize(12)
            .fontColor('#FF4444')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor('#2A2A2A')
            .borderRadius(12)
        }
      }
      .width('90%')

      List({ space: 6, scroller: this.notificationScroller }) {
        ForEach(this.notifications, (notif: Notification) => {
          ListItem() {
            NotificationCard({
              notification: notif,
              onRead: (id: string) => this.markAsRead(id)
            })
          }
        }, (notif: Notification) => notif.id)
      }
      .width('95%')
      .layoutWeight(1)
      .stackFromEnd(false)
      .scrollBar(BarState.Off)
      .borderRadius(12)

      Row({ space: 6 }) {
        Button('')
          .fontSize(16)
          .width(45)
          .height(40)
          .backgroundColor('#0A59F7')
          .onClick(() => this.addNotification())

        Button('📍 Latest')
          .fontSize(12)
          .layoutWeight(1)
          .height(40)
          .backgroundColor('#2A2A2A')
          .onClick(() => this.jumpToLatest())
      }
      .width('95%')
      .padding({ bottom: 8 })
    }
    .width('100%')
    .height('100%')
  }

  @Builder QuickReplyScenario() {
    Column({ space: 8 }) {
      Text('Quick Replies')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('90%')

      Text('Reply to messages with one tap')
        .fontSize(12)
        .fontColor('#888888')
        .width('90%')

      Grid() {
        ForEach(this.quickReplies, (reply: string, index: number) => {
          GridItem() {
            Text(reply)
              .fontSize(14)
              .fontColor('#FFFFFF')
              .width('100%')
              .height('100%')
              .textAlign(TextAlign.Center)
              .backgroundColor('#2A2A2A')
              .borderRadius(12)
              .onClick(() => {
                animateTo({ duration: 200 }, () => {
                  this.chatMessages.push({
                    id: String(this.chatCounter++),
                    text: reply,
                    time: this.getCurrentTime(),
                    isSent: true,
                    sender: 'You'
                  })
                })
                this.currentTab = 0
              })
          }
          .height(50)
        })
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr')
      .columnsGap(8)
      .rowsGap(8)
      .width('90%')
      .layoutWeight(1)

      Text('Your reply will be added to chat')
        .fontSize(11)
        .fontColor('#666666')
        .width('90%')
        .textAlign(TextAlign.Center)
        .padding({ bottom: 8 })
    }
    .width('100%')
    .height('100%')
  }
}

interface ChatMessage {
  id: string
  text: string
  time: string
  isSent: boolean
  sender: string
}

interface Notification {
  id: string
  title: string
  body: string
  time: string
  isRead: boolean
}

interface TabInfo {
  icon: string
  label: string
}

@Component
struct ChatAutoScroll {
  @Prop @Watch('scrollToBottom') messages: ChatMessage[] = []
  private scroller: ListScroller = new ListScroller()

  private scrollToBottom(): void {
    this.scroller.scrollEdge(Edge.Bottom)
  }

  build() {
    List({
      initialIndex: this.messages.length > 0 ? this.messages.length - 1 : 0,
      scroller: this.scroller
    }) {
      ForEach(this.messages, (msg: ChatMessage) => {
        ListItem() {
          Row() {
            if (!msg.isSent) {
              Blank().width(10)
            }

            Column({ space: 4 }) {
              Text(msg.sender)
                .fontSize(10)
                .fontColor('#888888')

              Text(msg.text)
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding(10)
                .backgroundColor(msg.isSent ? '#0A59F7' : '#2A2A2A')
                .borderRadius(12)

              Text(msg.time)
                .fontSize(10)
                .fontColor('#666666')
            }
            .alignItems(msg.isSent ? HorizontalAlign.End : HorizontalAlign.Start)
            .layoutWeight(1)

            if (msg.isSent) {
              Blank().width(10)
            }
          }
          .width('100%')
          .justifyContent(msg.isSent ? FlexAlign.End : FlexAlign.Start)
        }
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      }, (msg: ChatMessage) => msg.id)
    }
    .width('85%')
    .height('80%')
    .scrollBar(BarState.Off)
  }
}

@Component
struct NotificationCard {
  @Prop notification: Notification = {
    id: '0',
    title: '',
    body: '',
    time: '',
    isRead: false
  }
  onRead: (id: string) => void = () => {}

  build() {
    Column({ space: 6 }) {
      Row({ space: 8 }) {
        Text(this.notification.title)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)

        if (!this.notification.isRead) {
          Circle({ width: 8, height: 8 })
            .fill('#FF4444')
        }

        Text(this.notification.time)
          .fontSize(11)
          .fontColor('#888888')
      }
      .width('100%')

      Text(this.notification.body)
        .fontSize(12)
        .fontColor('#CCCCCC')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
    }
    .width('100%')
    .padding(12)
    .backgroundColor(this.notification.isRead ? '#1A1A1A' : '#2A2A2A')
    .borderRadius(12)
    .border({
      width: this.notification.isRead ? 0 : 1,
      color: '#0A59F7'
    })
    .onClick(() => {
      if (!this.notification.isRead) {
        this.onRead(this.notification.id)
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Results

  • Verified behaviors on emulator with API 19+:
    • Scenario 1: incoming items auto-scroll to show the newest.
    • Scenario 2: list grows from bottom; button jumps to latest.

2_.gif

Limitations or Considerations

  • For very large histories, consider batched/prepended loading and virtualization.
  • If you do image messages, prefer Image().syncLoad(true) to avoid flicker (see community tip below).
  • Ensure the newest-message focus doesn’t steal scroll when users are reading older messages (gate auto-scroll unless at or near bottom).

Related Documents or Links

Written by Bunyamin Eymen Alagoz

Top comments (0)