【综合案例】使用鸿蒙编写掘金评论列表案例

效果展示

功能描述

整个页面分为三大模块:顶部 + 主体【评论列表】 + 底部。

点击顶部的最新和最热按钮可以进行切换,点击最新按钮的时候主体部分的评论列表是按照时间由近至远进行排列展示,点击最热按钮的时候主体部分的评论列表是按照点赞数量从多到少进行排列展示的。

主体部分中可以对每一条评论进行点赞,当点赞的时候,该条评论的点赞图标及其颜色发生变化,该条评论的数量和颜色会发生变化。

底部区域中可以在输入框中进行评论的书写,书写完评论之后按下回车键可以将书写的内容添加到上方主体部分的评论列表当中,同时将输入框中的内容进行清空处理;但如果输入的内容为空的时候按回车键会弹出一个提示,警告用户输入的内容不能为空。

涉及知识

蓝色字体,如果是点击后不能进行链接跳转的,就说明在文章的下方对其有解释说明。

1、List 列表组件

列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。

仅支持ListItemListItemGroup子组件,支持渲染控制类型(if/elseForEachLazyForEachRepeat)。

List(value?:{space?: number | string, initialIndex?: number, scroller?: Scroller})

参数说明
参数名 类型 必填 说明
space number string 子组件主轴方向的间隔。 默认值:0 参数类型为number时单位为vp 说明:设置为负数或者大于等于List内容区长度时,按默认值显示。space参数值小于List分割宽度时,子组件主轴方向的间隔取分割线宽度。
initialIndex number 设置当前List初次加载时视口起始位置显示的item的索引值。 默认值:0 说明:设置为负数或超过了当前List最后一个item的索引值时视为无效取值,无效取值按默认值显示。
scroller Scroller 可滚动组件的控制器。用于与可滚动组件进行绑定。 说明:不允许和其他滚动类组件绑定同一个滚动控制对象。
属性

除支持通用属性滚动组件通用属性外,还支持以下属性:

属性 说明
listDirection(value: Axis) 设置List组件排列方向 默认值:Axis.Vertical
divider(value: {strokeWidth: Length ; color?: ResourceColor; startMargin?: Length; endMargin?: Length;} | null,) ListItem分割线样式。 - strokeWidth: 分割线的线宽。 - color: 分割线的颜色。 默认值:0x08000000 - startMargin: 分割线与列表侧边起始端的距离。 默认值:0,单位:vp - endMargin: 分割线与列表侧边结束端的距离。 默认值:0,单位:vp
scrollBar(value: BarState) 滚动条状态。 默认值:BarState.Auto 说明: API version 9及以下版本默认值为BarState.Off,API version 10及以上版本的默认值为BarState.Auto。
cachedCount(value: number) ListItem/ListItemGroup的预加载数量。 默认值:1
editMode(value: boolean) 当前List组件是否处于可编辑模式。 默认值:false
edgeEffect(value: EdgeEffect, options?: EdgeEffectOptions) 设置边缘滑动效果。
chainAnimation(value: boolean) 是否启用链式联动动效。 默认值:false,不启用链式联动。true,启用链式联动。
lanes(value: number | LengthConstrain, gutter?: Dimension) 设置List组件的布局列数或行数。gutter为列间距,当列数大于1时生效。
alignListItem(value: ListItemAlign) 交叉轴方向的布局方式。 默认值:ListItemAlign.Start
更多属性可参考官网...... ......
Axis参数取值说明
取值 描述
Vertical 方向为纵向
Horizontal 方向为横向
Length参数取值说明
取值 说明
string 需要显示指定像素单位,如'100px',也可以设置百分比字符串,比如'100%' 说明:不指定像素单位时,默认单位vp
number 默认单位vp
Resource 资源引用类型,引入系统资源或者引用资源中的尺寸
ResourceColor参数取值说明
取值 说明
Color 颜色枚举值
number HEX格式颜色,支持rgb或者argb。示例:0xffffff,0xffff0000。number无法识别传入位数,格式选择依据值的大小,例如0x00ffffff作rgb格式解析
string rgb或者argb格式颜色。示例:'#ffffff', '#ff000000', 'rgb(255, 100, 255)', 'rgba(255, 100, 255, 0.5)'。
Resource 使用引入资源的方式,引入系统资源或者应用资源中的颜色。
BarState参数取值说明
取值 说明
Off 不显示
On 常驻显示
Auto 按需显示(触摸时显示,2s后消失)
EdgeEffect参数取值说明
取值 说明
Spring 弹性物理动效,滑动到边缘后可以根据初始速度或通过触摸事件继续滑动一段距离,松手后回弹。
Fade 阴影效果,滑动到边缘后会有圆弧状的阴影。
None 滑动到边缘后无效果。

2、IconFont 字体图标的使用

①iconfont官网选图标 → 加入项目 → 下载

②注册成自定义字体

③设置字体使用

优势:任意放大缩小,不失真,可以修改颜色。

3、Prop - 父子单向

@Prop 装饰的变量可以和父组件建立单向的同步关系

@Prop 装饰的变量是可变的,但是变化不会同步回父组件

代码演示

index.ets

TypeScript 复制代码
import Header from '../components/Header'
import { infoItem } from '../components/infoItem'
import Footer from '../components/Footer'
import font from '@ohos.font';
import { CommentData, createListRange } from '../model/CommentData';

@Entry
@Component
struct Index {
  // 一加载index入口页面,就进行注册
  // aboutToAppear 会在组件一加载时,自动调用执行(生命周期函数)
  aboutToAppear(): void {
    // 注册字体
    font.registerFont({
      familyName: 'myFont',
      familySrc: '/fonts/iconfont.ttf'
    })
    // 已进入页面的时候先默认按照时间进行排序
    this.handleSort(1)
  }

  @State remarkList: CommentData[] = createListRange()

  //点赞
  handleLike(index: number) {
    // 子调用父的方法时,每个子组件都可以调用父组件的方法,需要加以区分【使用数组下标】
    // 对于复杂类型:状态对象、状态数组,只会对第一层数据,进行监视变化
    let itemData = this.remarkList[index]
    if (itemData.isLike) {
      itemData.likeNum -= 1
    } else {
      itemData.likeNum += 1
    }
    itemData.isLike = !itemData.isLike
    // 解决方法:使用 数组.splice(从哪开始删除,删除几个,替换的项1,替换的项2,...)
    this.remarkList.splice(index, 1, itemData)
  }

  // 处理评论回车提交
  handleSubmit(content: string) {
    // 往评论数组的最前边插入一条数据
    if (content.trim().length) {
      const newItem: CommentData =
        new CommentData('https://profile-avatar.csdnimg.cn/d29edf7348024b8f834e7a8b90887173_qq_45569925.jpg!1', '我',
          new Date().getTime(), 0, 0, content, false)
      this.remarkList.unshift(newItem)
    } else {
      AlertDialog.show({
        message: '输入的评论不能为空'
      })
    }

  }

  // 处理点击最新/最热进行排序
  handleSort(type: number) {
    // 按照最新进行排序
    if (type == 1) {
      this.remarkList = this.remarkList.sort((a, b) => {
        return b.time - a.time //按照时间进行由近到远进行排序
      })
    } else if (type == 2) {
      // 按照最热进行排序
      this.remarkList = this.remarkList.sort((a, b) => {
        // a:前一项,b:后一项,如果后一项➖前一项 > 0,则交换位置,否则不变
        return b.likeNum - a.likeNum //按照点赞数降序排列
      })
    }
  }

  build() {
    Column() {
      // 头部组件
      Header({
        onSort: (type: number) => {
          this.handleSort(type)
        }
      })
      // 主体内容组件
      List() {
        ForEach(this.remarkList, (item: CommentData, index: number) => {
          ListItem() {
            // 将item对象通过prop传值,传递下去
            infoItem({
              index: index,
              itemObj: item,
              /**父组件的方法,如果抽取出来,直接传递给子组件,
               * 会有this指向问题,this通常直接指向调用者
               * 需要用箭头函数包一层,保证this还是指向父组件
               * */
              onLikeClick: (index: number) => {
                // 此处的this指向的就是父组件
                this.handleLike(index)
              }
            })
          }
          .padding(10)
        })
      }
      .padding({ bottom: 10 })
      .layoutWeight(1)
      .width('100%')

      // 底部评论组件
      Footer({
        onSubmitComment: (content: string) => {
          this.handleSubmit(content)
        }
      })
    }
    .width("100%")
    .height("100%")
  }
}

Header.ets

TypeScript 复制代码
// 头部组件

@Extend(Button)
// isAct:当前项是否处于高亮状态
function btnStyle(isAct: boolean) {
  .width(46)
  .height(32)
  .fontSize(12)
  .padding({ left: 5, right: 5 })
  .backgroundColor(isAct ? '#fff' : '#F7F8FA')
  .fontColor('#2F2E33')
  .border({
    width: isAct ? 1 : 0,
    color: '#e4e5e6'
  })
}

@Component
struct Header {
  @State current: number = 1
  @State isAct: boolean = true
  onSort = (type: number) => {
  }

  build() {
    Row() {
      Text("全部评论")
        .fontSize(20)
        .fontWeight(700)
      Row() {
        // 如果不设置{ stateEffect: false },点击按钮的时候会有默认激活样式
        Button("最新", { stateEffect: false })
          .btnStyle(this.isAct)
          .onClick(() => {
            this.isAct = true
            this.onSort(1)
          })
        Button("最热", { stateEffect: false })
          .btnStyle(!this.isAct)
          .onClick(() => {
            this.isAct = false
            this.onSort(2)
          })
      }
      .borderRadius(20)
      .backgroundColor("#F7F8FA")
    }
    .padding(16)
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
}

export default Header

infoItem.ets

TypeScript 复制代码
import { CommentData } from '../model/CommentData'

@Component
export struct infoItem {
  @Prop index: number
  @Prop itemObj: CommentData
  onLikeClick = (index: number) => {
  }

  build() {
    Row() {
      Image(this.itemObj.avatar)
        .width(30)
        .aspectRatio(1)//保证宽高比是1:1
        .borderRadius(50)
      Column({ space: 8 }) {
        Row() {
          Text(this.itemObj.name)
            .margin({ left: 8, right: 12 })
            .fontSize(13)
            .fontColor(Color.Gray)
          Image(this.itemObj.levelIcon)
            .width(20)
            .aspectRatio(1) //保证宽高比是1:1
        }

        Text(this.itemObj.commentTxt)
          .margin({ left: 8 })
          .fontSize(13)
          .fontColor(Color.Black)
        Row() {
          Text(this.itemObj.timeString)
            .fontSize(10)
            .fontColor(Color.Gray)
          Row({ space: 2 }) {
            Text(this.itemObj.isLike ? '\uec8c' : '\ue600')
              .fontFamily('myFont')
              .fontSize(15)
              .fontColor(this.itemObj.isLike ? Color.Blue : '')
            Text(this.itemObj.likeNum.toString())
              .fontSize(12)
              .fontColor(this.itemObj.isLike ? Color.Blue : '')
          }
          .alignItems(VerticalAlign.Center)
          .onClick(() => {
            // 需要调用父组件的方法才能够真正的修改父组件的数据
            this.onLikeClick(this.index)
          })
        }
        .width('100%')
        .margin({ left: 8 })
        .padding({ right: 10 })
        .justifyContent(FlexAlign.SpaceBetween)
        .alignItems(VerticalAlign.Center)
        .align(Alignment.End)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)
    .justifyContent(FlexAlign.SpaceBetween)
  }
}

Footer.ets

TypeScript 复制代码
// 底部区域组件
@Component
struct Footer {
  @State msg: string = ''
  onSubmitComment = (msg: string) => {
  }

  build() {
    Row({ space: 10 }) {
      Row() {
        Text('\ue7a0')
          .fontFamily('myFont')
          .fontColor(Color.Gray)
        TextInput({
          placeholder: '写评论...',
          // text: this.msg, // 单向数据绑定,即输入框中的值改变的时候不会印象msg的值
          text: $$this.msg // 双向数据绑定
        })
          .height('100%')
          .backgroundColor(Color.Transparent)// 监听输入框的回车事件
          .onSubmit(() => {
            // 这里不能直接添加,需要调用父组件传递过来的方法
            this.onSubmitComment(this.msg)
            // 按下回车键将数据添加成功之后,将输入框清空
            this.msg = ''
          })
      }
      .padding({ left: 20, right: 20 })
      .layoutWeight(1)
      .height(40)
      .borderRadius(20)
      .backgroundColor('#f6f6f6')

      Text('\ue600')
        .fontFamily('myFont')
        .fontSize(20)
      Text('\ue607')
        .fontFamily('myFont')
        .fontSize(18)
    }
    .padding(10)
    .width("100%")
    .height(50)
    .backgroundColor(Color.White)
  }
}

export default Footer

CommentData.ets

TypeScript 复制代码
// 准备评论的数据类
export class CommentData {
  avatar: string; // 头像
  name: string; //昵称
  level: number; //用户等级
  likeNum: number; //点赞数量
  commentTxt: string; //评论内容
  isLike: boolean; //是否点赞
  levelIcon: Resource; //level等级
  timeString: string; //发布时间--基于时间戳处理后,展示给用户看的
  time: number; //时间戳

  constructor(avatar: string, name: string, time: number, level: number, likeNum: number, commentTxt: string,
    isLike: boolean) {
    this.avatar = avatar
    this.name = name
    this.level = level
    this.likeNum = likeNum
    this.commentTxt = commentTxt
    this.isLike = isLike
    this.levelIcon = this.convertLevel(this.level)
    this.timeString = this.convertTime(time)
    this.time = time
  }

  // 基于传入的level,转换为图片路径
  convertLevel(level: number): Resource {
    return $r(`app.media.level_${level}`)
  }

  convertTime(lastTimeSatmp: number): string {
    const nowTimestamp = new Date().getTime() //当前时间戳
    const timeDifference = (nowTimestamp - lastTimeSatmp) / 1000 //转换为秒【当前时间与传入时间之间的时间差】

    if (timeDifference < 0 || timeDifference == 0) {
      return '刚刚'
    } else if (timeDifference < 60) {
      return `${Math.floor(timeDifference)}秒前`
    } else if (timeDifference < 3600) {
      return `${Math.floor(timeDifference / 60)}分钟前`
    } else if (timeDifference < 86400) {
      return `${Math.floor(timeDifference / 3600)}小时前`
    } else if (timeDifference < 604800) {
      return `${Math.floor(timeDifference / 86400)}天前`
    } else if (timeDifference < 31536000) {
      return `${Math.floor(timeDifference / 2592000)}个月前`
    } else {
      return `${Math.floor(timeDifference / 31536000)}年前`
    }
  }
}

// 封装一个方法, 创建假数据
export const createListRange = (): CommentData[] => {
  let result: CommentData[] = new Array()
  result = [
    new CommentData(`https://fastly.picsum.photos/id/770/600/600.jpg?hmac=tuK9EHg1ifTU3xKAiZj2nHSdWy4mk7enhylgOc2BW7E`,
      "雪山飞狐", 1725503122785, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '23年一年干完的事😂😂😂真的非常仓促', false),
    new CommentData(`https://fastly.picsum.photos/id/225/600/600.jpg?hmac=v97zt_t4mxeyMttX_m09pxhCvftiTxFR1MMBZi5HQxs`,
      "千纸鹤", 1725504541300, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      'Netty对象池源码分析来啦!juejin.cn欢迎点赞[奸笑]', false),
    new CommentData(`https://fastly.picsum.photos/id/122/600/600.jpg?hmac=1oA93YbjYVt96DcJcGQ5PLthzjUsdtrnBQaM0USBozI`,
      "烟雨江南", 1725408000000, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '有一个不听劝的Stable Diffusion出图的小伙伴,找我给她装填脑。 一个资深的IT工程师不能受这个委屈。', false),
    new CommentData(`https://fastly.picsum.photos/id/654/600/600.jpg?hmac=ewnK6Bx_MKQLJa9waZOV1xNO7--K5oSwCShtz1JDYw8`,
      "魔法小精灵", 1697484201123, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '有一点可以确信 前后端开发界限越来越模糊。后端可以不写 但是不能不会。', false),
    new CommentData(`https://fastly.picsum.photos/id/345/600/600.jpg?hmac=EQflzbIadAglm0RzotyKXM2itPfC49fR3QE7eW_UaPo`,
      "独行侠", 1704067200000, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '今天看到一个帖子,挺有意思的。', false),
    new CommentData(`https://fastly.picsum.photos/id/905/600/600.jpg?hmac=DvIKicBZ45DEZoZFwdZ62VbmaCwkK4Sv7rwYzUvwweU`,
      "枫叶飘零", 1706745600000, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '我想了搞钱的路子, 1:投资理财, 后来发下,不投资就是最好的理财, 2:买彩票,后来发现彩票都是人家预定好的,根本不是随机的,卒, 3:开店创业,隔行如隔山,开店失败,卒。',
      false),
    new CommentData(`https://fastly.picsum.photos/id/255/600/600.jpg?hmac=-lfdnAl71_eAIy1OPAupFFPh7EOJPmQRJFg-y7lRB3s`,
      "星空漫步", 1707523200000, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '优胜劣汰,自然选择吧,没什么好怪的。都是crud,招个大学生就能干了。', false),
    new CommentData(`https://fastly.picsum.photos/id/22/600/600.jpg?hmac=QEZq7KUHwBZCt3kGSEHMwJlZfnzCxCeBgHjYj7iQ-UY`,
      "剑指苍穹", 1708300800000, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100),
      '白嫖ChatGPT4的功能。然后,抱着试一试的态度把玩了一下。发现真的好用。', false),
    new CommentData(`https://fastly.picsum.photos/id/439/600/600.jpg?hmac=LC9k_bzrN0NhKRyV62fou3ix3cRFZKNfAyXgxGs6zh8`,
      "黑暗王国", 1708646400000, Math.floor(Math.random() * 6), Math.floor(Math.random() * 100), '字数越少,事情越大。',
      false),
  ]
  return result
}
相关推荐
GIS之路3 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug7 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121389 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中30 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路34 分钟前
GDAL 实现矢量合并
前端
hxjhnct36 分钟前
React useContext的缺陷
前端·react.js·前端框架
前端世界1 小时前
设备找不到、Ability 启不动?一次讲清 DevEco Studio 调试鸿蒙分布式应用
华为·harmonyos
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端