DEV Community

HarmonyOS
HarmonyOS

Posted on

How to achieve the WeChat Moments effect

Read the original article:How to achieve the WeChat Moments effect

How to achieve the WeChat Moments effect

Requirement Description

How to achieve the WeChat Moments effect?

Background Knowledge

The Listcomponent contains a series of list items of the same width. It is suitable for presenting similar data continuously and in multiple rows, such as images and text.

LazyForEachiterates over data from the data source on demand and creates corresponding components during each iteration. When LazyForEachis used in a scrolling container, the framework creates components on demand based on the visible area of the scrolling container. When components scroll out of the visible area, the framework destroys and recycles them to reduce memory usage.

Refresh is a container component that allows for page pull-down operations and displays refresh animations. @ObservedV2 and @Trace are core capabilities in state management V2. These decorators are used to decorate classes and properties within classes, enabling the decorated classes and properties to have deep observation capabilities:

Implementation Steps

Create a Refreshcomponent on the main page and use LazyForEach within this component to render MomentListItem. Implement the alternating rendering of image and video components in a specific manner, which is the framework functionality of this demo.

In the MomentListItemcomponent, display the user's nickname, avatar, time, text content, comments, and likes. Depending on the data attributes, display images and videos accordingly.

Code Snippet / Configuration

// Moment.ets

import { CommonDataSource } from './CommonDataSource';
import { MomentItem } from './ListModel';
import { MomentListItem } from './MomentListItem';

@Entry
@Component
struct Moments {
  @State isRefreshing: boolean = false
  private listDataSource = new CommonDataSource<MomentItem>()

  aboutToAppear(): void {
    this.getListData()
  }

  getListData() {
    let followList: MomentItem[] = []
    for (let i = 0; i < 10; i++) {
      //The 'itemData value' here is for illustration purposes only. 
      let itemData: MomentItem = new MomentItem()
      itemData.id = 'id_' + i
      itemData.userId = 'userId_' + i
      itemData.userName = 'userName'
      itemData.title = '6666'
      itemData.postingTime = '2025/07/09 10:30:00'
      itemData.isFollow = false
      if (i % 2 === 0) {
        itemData.images = [
        // Here, 'app.media.xxx' is used as an example only. 
          $r('app.media.img1'),
          $r('app.media.img2'),
          $r('app.media.img3'),
          $r('app.media.img4'),
          $r('app.media.img5'),
          $r('app.media.img6'),
          $r('app.media.img7'),
          $r('app.media.img8'),
          $r('app.media.img9'),
        ]
      } else {
        // Here, 'video.mp4' is used as an example only. 
        itemData.videoUrl = $rawfile('video.mp4')
      }
      itemData.likeCount = i
      itemData.favorite = i
      followList.push(itemData)
    }
    this.listDataSource.setData(followList)
  }

  @Builder
  ListContent() {
    Refresh({ refreshing: $$this.isRefreshing }) {
      List({ space: 20 }) {
        LazyForEach(this.listDataSource, (item: MomentItem, index: number) => {
          ListItem() {
            MomentListItem({
              data: item
            })
          }
          .padding({ left: 14, right: 14 })
          .backgroundColor(Color.White)
          .borderRadius(6)
        })
      }
      .width('100%')
      .height('100%')
      .padding({ left: 14, right: 14 })
    }
    .onRefreshing(() => {
      setTimeout(() => {
        this.isRefreshing = false
      }, 1000)
    })
    .pullToRefresh(true)
  }

  build() {
    Column() {
      this.ListContent()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#fff6f3f3')
  }
}
Enter fullscreen mode Exit fullscreen mode
// ListModel.ets
@ObservedV2
export class MomentItem {
  id: string = ''
  userId: string = ''
  @Trace userName: string = ''
  // Here, 'app.media.startIcon' is used as an example only. 
  @Trace userIcon: Resource = $r('app.media.startIcon')
  @Trace title: string = ''
  @Trace postingTime: string = ''
  @Trace isFollow: boolean = false
  @Trace images: Resource[] = []
  @Trace videoUrl: Resource | string = ''
  @Trace videoPreviewUrl: Resource | string = ''
  @Trace likeCount: number = 0
  @Trace shareCount: number = 0
  @Trace commentCount: number = 0
  favorite: number = 0
}
Enter fullscreen mode Exit fullscreen mode
// MomentListItem.ets
import { MomentItem } from './ListModel'

@Component
export struct MomentListItem {
  data: MomentItem | null = null
  controller: VideoController = new VideoController();
  isPlay: boolean = false

  getPostingTime(time: string = ''): string {
    let date: Date = new Date(time)

    let M: number = date.getMonth() + 1
    let D: number = date.getDate()
    let h: number = date.getHours()
    let m: number = date.getMinutes()

    return `${M}-${D} ${h}:${m}`
  }

  build() {
    Column() {
      Row() {
        Image(this.data?.userIcon).width(30).height(30)
        Column() {
          Text(this.data?.userName).fontSize(13).fontWeight(FontWeight.Bold)
          Text(this.getPostingTime(this.data?.postingTime))
            .fontSize(10)
            .fontColor('#ffbab8b8')
            .margin({ top: 2 })
        }
        .offset({ x: 8 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')

      Row() {
        Text(this.data?.title)
          .fontSize(16)
          .fontColor('#191919')
          .margin({ top: 12 })
      }
      .justifyContent(FlexAlign.Start)
      .width('100%')

      Column() {
        if (this.data?.images && this.data.images.length) {
          if (this.data.images.length > 2) {
            this.imageGridBuilder(this.data.images)
          } else {
            this.imageBuilder(this.data.images)
          }
        }
        if (this.data?.videoUrl) {
          this.videoBuilder(this.data)
        }
      }
      .margin({ top: 8 })
      .alignItems(HorizontalAlign.Start)

      //Check if there are comments
      if (true) {
        Column({ space: 10 }) {
          ForEach([1, 2, 3], () => {
            this.CommentListView()
          })
          //Check if the number of comments exceeds 3
          if (true) {
            Text('View more replies')
              .fontColor('#007dff')
              .fontSize(12)
              .margin({ left: 38 })
              .onClick(() => {
              })
          }
        }
        .padding({
          left: 14,
          right: 14,
          top: 14,
          bottom: 12
        })
        .backgroundColor('#ffeae9e9')
        .margin({ top: 10 })
        .borderRadius(4)
        .alignItems(HorizontalAlign.Start)
      }

      Row({ space: 40 }) {
        //'app.media.xxx' is only an example.
        this.cellItemBuilder($r('app.media.comment'),
          this.data?.commentCount && this.data.commentCount > 0 ? this.data?.commentCount + '' :  'Comments')
        this.cellItemBuilder($r('app.media.thumbup'),
          this.data?.likeCount && this.data.likeCount > 0 ? this.data?.likeCount + '' : 'like')
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
      .margin({
        top: 20,
        bottom: 20,
      })
    }
    .width('100%')
    .height('auto')
    .margin({ top: 16 })
  }

  @Builder
  CommentListView() {
    Column() {
      Row() {
        Image(this.data?.userIcon).width(30).height(30)
        Column() {
          Text(this.data?.userName).fontSize(12).fontColor('#999')
          Text(this.getPostingTime(this.data?.postingTime))
            .fontSize(10)
            .fontColor('#ffb1b0b0')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .offset({ x: 8 })
      }
      .width('100%')

      Text(this.data?.title)
        .fontSize(12)
        .fontColor('#191919')
        .margin({ left: 38 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  imageBuilder(images: Resource[]) {
    Row({ space: 6 }) {
      ForEach(images, (item: Resource, index) => {
        Image(item).width(`${100 / 2}%`)
      })
    }
  }

  @Builder
  imageGridBuilder(images: Resource[]) {
    Grid() {
      ForEach(images, (item: Resource) => {
        GridItem() {
          Column() {
            Image(item)
              .borderRadius(4)
              .width('calc((100% - 16vp) / 3)')
              .aspectRatio(1)
          }
          .onClick(() => {
          })
        }
      })
    }
    .width('100%')
    .constraintSize({ maxWidth: '100%' })
    .maxCount(3)
    .columnsGap(8)
    .rowsGap(8)
    .backgroundColor(Color.White)

  }

  @Builder
  videoBuilder(item: MomentItem) {
    Stack() {
      Video({ src: item.videoUrl, controller: this.controller })
        .width('50%')
        .height(220)
        .loop(false)
        .objectFit(ImageFit.Contain)
        .autoPlay(true)
        .controls(false)
    }
    .onClick(() => {
      this.isPlay = !this.isPlay
      if (this.isPlay) {
        this.controller.start()
      } else {
        this.controller.pause()
      }
    })
  }

  @Builder
  cellItemBuilder(icon: Resource, text: string) {
    Row() {
      Image(icon).width(16).height(16)
      Text(text).fontSize(12).margin({ left: 2 })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// CommonDataSource.ets

export class CommonDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  originDataArray: T[] = [];

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

  getAllData(): T[] {
    return this.originDataArray
  }

  getData(index: number) {
    return this.originDataArray[index];
  }

  addData(index: number, data: T): void {
    this.originDataArray.splice(index, 0, data);
    this.notifyDataAdd(index);
  }

  pushData(data: T): void {
    this.originDataArray.push(data);
    this.notifyDataAdd(this.originDataArray.length - 1);
  }

  pushDataArray(...items: T[]): void {
    for (let data of items) {
      this.originDataArray.push(data);
      this.notifyDataAdd(this.originDataArray.length - 1);
    }
  }

  deleteDataUseContent(data: T): void {
    let delIndex: number = -1
    for (let index = 0; index < this.originDataArray.length; index++) {
      const element = this.originDataArray[index];
      if (data === element) {
        delIndex = index
      }
    }
    if (delIndex !== -1) {
      this.deleteData(delIndex)
    }
  }

  deleteData(index: number): void {
    this.originDataArray.splice(index, 1);
    this.notifyDataDelete(index);
  }

  clear() {
    this.originDataArray.splice(0, this.originDataArray.length)
    this.listeners.forEach(listener => {
      listener.onDataDelete(0)
    })
  }

  setData(dataArray?: T[]) {
    if (dataArray) {
      this.originDataArray = dataArray
    } else {
      this.originDataArray = []
    }
    this.notifyDataReload()
  }

  refreshDataByIndex(start: number, end: number, dataArray: T[]) {
    this.originDataArray.splice(start, end - start, ...dataArray);
    this.notifyDataReload()
  }

  changeData(index: number, data: T): void {
    this.originDataArray.splice(index, 1, data);
    this.notifyDataChange(index);
  }

  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);
    }
  }

  notifyDataReload() {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    })
  }

  notifyDataAdd(index: number) {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    })
  }

  notifyDataMove(from: number, to: number) {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to);
    })
  }

  notifyDataDelete(index: number) {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
    })
  }

  notifyDataChange(index: number) {
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Results

image-watch.png

Limitations or Considerations

This example supports API Version 19 Release and above.

This example supports HarmonyOS 5.1.1 Release SDK and above.

This example requires DevEco Studio 5.1.1 Release and above for compilation.

Written by Seyda Kececi

Top comments (0)