Chat List Essentials on HarmonyOS: Auto-Scroll to Bottom with @Watch and Bottom-Stacked Lists
Requirement Description
Implement two common IM chat behaviors:
- Always show newest messages at the bottom and auto-scroll when new data arrives.
- 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.
GuidestackFromEnd: List property to layout from the bottom.
ReferencescrollEdge(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
@Watchon the message list (as a prop in the chat component) to callscrollEdge(Edge.Bottom)whenever the list changes. - Set
initialIndexto 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
ListScrollerand bind a button toscrollEdge(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)
}
})
}
}
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.
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).

Top comments (0)