概要
黑马健康系统:实现了记录页面的数据统计卡片StatsCard,和记录列表RecordList,至此记录页面主页面RecordIndex的基本样式和列表项主页面ItemIndex的样式已经完成。
整体流程
1.预期效果
预期①日期弹窗DatePickDialog
预期②热量统计CalorieStats
预期③营养素统计NutrientStats
预期④记录列表RecordList
预期⑤列表项(食物和运动)主页面ItemIndex
预期⑥具体项的列表ItemList
2.结构框架
3.具体代码
1.预期①
import { CommonConstants } from '../../common/constants/CommonConstants'
@CustomDialog
export default struct DatePickDialog {
controller: CustomDialogController
selectedDate: Date = new Date()
build() {
Column({space: CommonConstants.SPACE_12}){//上至下布局
// 1.日期选择器 查看API
DatePicker({
start: new Date('2020-01-01'),
end: new Date(),
selected: this.selectedDate
})
.onChange((value: DatePickerResult) => {//用户选择日期传入
this.selectedDate.setFullYear(value.year, value.month, value.day)
})
// 2.按钮
Row({space:CommonConstants.SPACE_12}){//按钮放到一行
Button('取消')
.width(120)
.backgroundColor($r('app.color.light_gray'))
.onClick(() => this.controller.close())
Button('确定')
.width(120)
.backgroundColor($r('app.color.primary_color'))
.onClick(() => {//日期用处很多
// 1.保存日期到全局存储
AppStorage.SetOrCreate('selectedDate', this.selectedDate.getTime())//存毫秒值,不存日期
// 2.关闭窗口
this.controller.close()
})
}
}
.padding(CommonConstants.SPACE_12)
}
}
2.预期②
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct CalorieStats {
@Prop intake: number//摄入
@Prop expend: number//消耗
recommend: number = CommonConstants.RECOMMEND_CALORIE//推荐值
remainCalorie(){
return this.recommend - this.intake + this.expend
}//得到还可以吃的
build() {
Row({space: CommonConstants.SPACE_6}){
// 1.饮食摄入
this.StatsBuilder({label: '饮食摄入', value: this.intake})//传入参数
// 2.还可以吃
Stack(){//层叠关系
// 2.1.进度条
Progress({
value: this.intake,//吃了多少
total: this.recommend,
type: ProgressType.Ring//环形记录条
})
.width(120)
.style({strokeWidth: CommonConstants.DEFAULT_10})//环的粗细
.color($r('app.color.primary_color'))
// 2.2.统计数据
this.StatsBuilder({label: '还可以吃', value: this.remainCalorie(),tips: `推荐${this.recommend}`})
}
// 3.运动消耗
this.StatsBuilder({label: '运动消耗', value: this.expend})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)//均匀分配空间
.padding({top: 30, bottom: 35})//上下分配
}
//对重复部分进行抽取,文字说明,数字,补充说明(可以不传),传值,不传递引用
@Builder StatsBuilder($$:{label: string, value: number, tips?: string}){
Column({space: CommonConstants.SPACE_6}){
Text($$.label)//还可以吃
.fontColor($r('app.color.gray'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
Text($$.value.toFixed(0))
.fontSize(20)
.fontWeight(CommonConstants.FONT_WEIGHT_700)
if($$.tips){
Text($$.tips)
.fontSize(12)
.fontColor($r('app.color.light_gray'))
}
}
}
}
3.预期③
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct NutrientStats {
@Prop carbon: number//碳水
@Prop protein: number//蛋白质
@Prop fat: number//脂肪
recommendCarbon: number = CommonConstants.RECOMMEND_CARBON
recommendProtein: number = CommonConstants.RECOMMEND_PROTEIN
recommendFat: number = CommonConstants.RECOMMEND_FAT
build() {
Row({space: CommonConstants.SPACE_6}){
this.StatsBuilder({
label: '碳水化合物',
value: this.carbon,
recommend: this.recommendCarbon,
color: $r('app.color.carbon_color')
})
this.StatsBuilder({
label: '蛋白质',
value: this.protein,
recommend: this.recommendProtein,
color: $r('app.color.protein_color')
})
this.StatsBuilder({
label: '脂肪',
value: this.fat,
recommend: this.recommendFat,
color: $r('app.color.fat_color')
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding({top: 30, bottom: 35})
}
//颜色各不相同
@Builder StatsBuilder($$:{label: string, value: number, recommend: number, color: ResourceStr}){
Column({space: CommonConstants.SPACE_6}){
Stack(){
Progress({
value: $$.value,
total: $$.recommend,
type: ProgressType.Ring
})
.width(95)
.style({strokeWidth: CommonConstants.DEFAULT_6})
.color($$.color)
Column({space: CommonConstants.SPACE_6}){
Text('摄入推荐')
.fontSize(12)
.fontColor($r('app.color.gray'))
Text(`${$$.value.toFixed(0)}/${$$.recommend.toFixed(0)}`)
.fontSize(18)
.fontWeight(CommonConstants.FONT_WEIGHT_600)
}
}
Text(`${$$.label}(克)`)
.fontSize(12)
.fontColor($r('app.color.light_gray'))
}
}
}
4.预期④
import router from '@ohos.router'
import { CommonConstants } from '../../common/constants/CommonConstants'
import RecordService from '../../service/RecordService'
import GroupInfo from '../../viewmodel/GroupInfo'
import RecordType from '../../viewmodel/RecordType'
import RecordVO from '../../viewmodel/RecordVO'
@Extend(Text) function grayText(){//通用样式
.fontSize(14)
.fontColor($r('app.color.light_gray'))
}
@Component
export default struct RecordList {
@Consume @Watch('handleRecordsChange') records: RecordVO[]
@State groups: GroupInfo<RecordType, RecordVO>[] = []
handleRecordsChange(){
this.groups = RecordService.calculateGroupInfo(this.records)
}
build() {
List({space: CommonConstants.SPACE_10}){//List内部渲染
ForEach(this.groups, (group: GroupInfo<RecordType, RecordVO>) => {
ListItem(){
Column({space: CommonConstants.SPACE_8}){
// 1.分组的标题
Row({space: CommonConstants.SPACE_4}){//水平布局,行内元素
Image(group.type.icon).width(24)
Text(group.type.name)
.fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)
Text(`建议${group.type.min}~${group.type.max}千卡`).grayText()
Blank()//空白
Text(group.calorie.toFixed(0))
.fontSize(14).fontColor($r('app.color.primary_color'))
Text('千卡').grayText()
Image($r('app.media.ic_public_add_norm_filled'))
.width(20)
.fillColor($r('app.color.primary_color'))//svg图片可添加颜色
}
.width('100%')
.onClick(() => {
router.pushUrl({
url: 'pages/ItemIndex',
params: {type: group.type}
})
})
// 2.组内记录列表
List(){
ForEach(group.items, (item: RecordVO) => {
ListItem(){
Row({space: CommonConstants.SPACE_6}){//有很多这样的行
Image(item.recordItem.image).width(50)
Column({space: CommonConstants.SPACE_4}){//图片列式
Text(item.recordItem.name).fontWeight(CommonConstants.FONT_WEIGHT_500)
Text(`${item.amount}${item.recordItem.unit}`).grayText()
}
Blank()
Text(`${item.calorie.toFixed(0)}千卡`).grayText()
}
.width('100%')
.padding(CommonConstants.SPACE_6)
}.swipeAction({end: this.deleteButton.bind(this)})//侧滑效果,可以删除操作
})
}
.width('100%')
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(CommonConstants.DEFAULT_18)//卡片的基本样式
.padding(CommonConstants.SPACE_12)
}
})
}
.width(CommonConstants.THOUSANDTH_940)
.height('100%')//list内部高度
.margin({top: 10})
}
@Builder deleteButton(){
Image($r('app.media.ic_public_delete_filled'))
.width(20)
.fillColor(Color.Red)
.margin(5)//与其他产生间距
}
}
5.预期⑤
import router from '@ohos.router'
import { CommonConstants } from '../common/constants/CommonConstants'
import { RecordTypeEnum, RecordTypes } from '../model/RecordTypeModel'
import RecordService from '../service/RecordService'
import ItemCard from '../view/item/ItemCard'
import ItemList from '../view/item/ItemList'
import ItemPanelHeader from '../view/item/ItemPanelHeader'
import NumberKeyboard from '../view/item/NumberKeyboard'
import RecordItem from '../viewmodel/RecordItem'
import RecordType from '../viewmodel/RecordType'
@Extend(Button) function panelButtonStyle(){
.width(120)
.type(ButtonType.Normal)
.borderRadius(6)
}
@Entry
@Component
struct ItemIndex {
//需要传递一个状态变量,需要提前声明
@State amount:number=1
@State value:string=''
@State showPanel:boolean=false//什么时候展示面板
@State item:RecordItem=null
@State type:RecordType=RecordTypes[0]
@State isFood:boolean=true
onPanelShow(item: RecordItem) {
this.amount = 1
this.value = ''
this.item = item
this.showPanel = true
}
//当面板展示的时候
onPageShow(){
// 1.获取跳转时的参数
let params: any = router.getParams()
// 2.获取点击的饮食记录类型
this.type = params.type
this.isFood = this.type.id !== RecordTypeEnum.WORKOUT
}
build() {
Column() {
//1.头部导航
this.Header()
//2.列表
ItemList({showPanel:this.onPanelShow.bind(this),isFood:this.isFood})
.layoutWeight(1)
//3.底部面板
Panel(this.showPanel){
//3.1顶部日期
ItemPanelHeader()
//3.2记录项卡片
if(this.item){
ItemCard({amount:this.amount,item:$item})
}
//3.3数字键盘
NumberKeyboard({amount:$amount,value:$value})
//3.4按钮
Row({space:CommonConstants.SPACE_6}){
Button('取消')
.width(120)
.backgroundColor($r('app.color.light_gray'))
.type(ButtonType.Normal)
.borderRadius(6)
.onClick(()=>this.showPanel=false)
Button('提交')
.width(120)
.backgroundColor($r('app.color.primary_color'))
.type(ButtonType.Normal)
.borderRadius(6)
.onClick(()=>this.showPanel=false)
}
.margin({top:10})
}
.mode(PanelMode.Full)
.dragBar(false)
.backgroundMask($r('app.color.light_gray'))
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
}
@Builder PanelButton(){
Row({space: CommonConstants.SPACE_6}){
Button('取消')
.panelButtonStyle()
.backgroundColor($r('app.color.light_gray'))
.onClick(() => this.showPanel = false)
Button('提交')
.panelButtonStyle()
.backgroundColor($r('app.color.primary_color'))
.onClick(() => {
// 1.持久化保存
RecordService.insert(this.type.id, this.item.id, this.amount)
.then(() => {
// 2.关闭弹窗
this.showPanel = false
})
})
}
.margin({top: 10})
}
@Builder Header() {
Row() {
Image($r('app.media.ic_public_back'))
.width(24)
.onClick(() => router.back())
Blank()
Text(this.type.name).fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_600)
}
.width(CommonConstants.THOUSANDTH_940)
.height(32)
}
}
6.预期⑥
import { CommonConstants } from '../../common/constants/CommonConstants'
import ItemModel from '../../model/ItemModel'
import GroupInfo from '../../viewmodel/GroupInfo'
import ItemCategory from '../../viewmodel/ItemCategory'
import RecordItem from '../../viewmodel/RecordItem'
@Component
export default struct ItemList {
showPanel:(item:RecordItem)=>void
@Prop isFood:boolean
build() {
Tabs(){
TabContent(){
this.TabContentBuilder(ItemModel.list(this.isFood))
}
.tabBar('全部')
ForEach( ItemModel.listItemGroupByCategory(this.isFood),
(group: GroupInfo<ItemCategory, RecordItem>) => {
TabContent() {
this.TabContentBuilder(group.items)
}
.tabBar(group.type.name)
})
}
.width(CommonConstants.THOUSANDTH_940)
.height('100%')
}
@Builder TabContentBuilder(items:RecordItem[]){
List({space:CommonConstants.SPACE_10}){
ForEach(items, (item:RecordItem) => {
ListItem(){
Row({space: CommonConstants.SPACE_6}){//有很多这样的行
Image(item.image).width(50)
Column({space: CommonConstants.SPACE_4}){//图片列式
Text(item.name).fontWeight(CommonConstants.FONT_WEIGHT_500)
Text(`${item.calorie}${item.unit}`)
.fontSize(14)
.fontColor($r('app.color.light_gray'))
}
Blank()
Image($r('app.media.ic_public_add_norm_filled'))
.width(18)
.fillColor($r('app.color.primary_color'))
}
.width('100%')
.padding(CommonConstants.SPACE_6)
}
.onClick(()=> this.showPanel(item))
})
}
.width('100%')
.height('100%')
}
}
实现步骤
1.在记录页面主页面RecordIndex 完成浅绿色日期框,点击时跳到日期选择页面DatePickDialog,所以我们需要开发一个弹窗,弹窗在数据统计卡片StatsCard应用,然后我们对统计卡片添加一个滑动框,包含两部分内容,热量统计和营养素统计,因为这两部分内容比较多,所以我们新建立一个组件热量统计页面CalorieStats去编写,以及营养素统计页面NutrientStats去编写,到此我们记录页面主页面的统计卡片完成。
2.记录页面主页面RecordIndex的第三部分,记录列表RecordList还是单独建立一个组件,来保证记录页面主页面代码的规范性。
3.我们完成了记录页面RecordList的开发,在此页面有不同的记录分组,比如早餐,晚餐.当用户点击某个分组时,就会跳转进入食物列表的页面,在食物列表里面添加饮食记录。食物列表是一个新的页面所以我们要新建ItemIndex(因为我们要添加的信息不止是食物,还有运动所以起名为项的页面,便于理解我们叫做列表项主页面),在pages目录下因为它不属于记录页面主页的。
4.列表项主页面ItemIndex包括头部导航,食物(或运动)列表和弹出底部面板三部分。因为第二部分的列表内容很多,我们在view目录下重新建立一个item文件夹,在文件夹里创建具体项的列表组件ItemList。
新的API
1.DatePicker日期选择器
查看API,其中的属性有lunar代表是否显示农历,选择日期时触发该事件。
// xxx.ets
@Entry
@Component
struct DatePickerExample {
@State isLunar: boolean = false
private selectedDate: Date = new Date('2021-08-08')
build() {
Column() {
Button('切换公历农历')
.margin({ top: 30, bottom: 30 })
.onClick(() => {
this.isLunar = !this.isLunar
})
DatePicker({
start: new Date('1970-1-1'),
end: new Date('2100-1-1'),
selected: this.selectedDate
})
.lunar(this.isLunar)
.onChange((value: DatePickerResult) => {
this.selectedDate.setFullYear(value.year, value.month, value.day)
console.info('select current date is: ' + JSON.stringify(value))
})
}.width('100%')
}
}
2.AppStorage全局存储
里面包含的SetOrCreate<T>函数是Key-Value键值对形式。其中,尽量不要存date对象,将来做状态监控变量时日期date会有问题,存它所对应的毫秒值(是基础类型number可以做状态监控),不存日期
static SetOrCreate<T>(propName: string, newValue: T): void
propName如果已经在AppStorage中存在,则设置propName对应是属性的值为newValue。如果不存在,则创建propName属性,值为newValue。
// 1.保存日期到全局存储
AppStorage.SetOrCreate('selectedDate', this.selectedDate.getTime())
//存它所对应的毫秒值(是基础类型number可以做状态监控),不存日期
3.Swiper滑动组件
子组件类型:系统组件和自定义组件,不支持渲染控制类型(if/else、ForEach和LazyForEach)。
接口
Swiper(controller?: SwiperController)
// 2.统计信息,可滑动的信息
Swiper(){
// 2.1.热量统计
CalorieStats({intake: this.info.intake, expend: this.info.expend})
// 2.2.营养素统计
NutrientStats({carbon: this.info.carbon, protein: this.info.protein, fat: this.info.fat})
}
.width('100%')//占据整个屏幕
.backgroundColor(Color.White)
.borderRadius(CommonConstants.DEFAULT_18)//园边框
.indicatorStyle({selectedColor: $r('app.color.primary_color')})//穿梭框的颜色
4.Progress进度条
进度条组件,用于显示内容加载或操作处理等进度。因为是两个组件添加,还需要一个Stack堆叠容器,子组件按照顺序依次入栈,后一个子组件覆盖前一个子组件。
//层叠关系
Stack(){
// 2.1.进度条
Progress({
value: this.intake,//吃了多少
total: this.recommend,
type: ProgressType.Ring//环形记录条
})
.width(120)
.style({strokeWidth: CommonConstants.DEFAULT_10})//环的粗细
.color($r('app.color.primary_color'))
// 2.2.统计数据
this.StatsBuilder({label: '还可以吃', value: this.remainCalorie(),tips: `推荐${this.recommend}`})
}
技术小结
1.读取日期
在数据统计主页要读取日期,因为我们将日期存到了AppStorage中,所有我们用@StorageProp去读取(prop单向绑定,link双向绑定),类型是number类型,必须要做初始化,初始化值是当前日期,默认的日期会加上时分秒,我们存的仅是年月日,我们想要的是这一天的零时零分零秒,用DateUtil来获取这一天的开始日期。同时我们选择的日期要传到这个弹窗里,因为下一次再打开的时候要把这个日期显示出来,叫回显。并且要把日期转换成date类型
class DateUtil{
formatDate(num: number): string{
let date = new Date(num)
let year = date.getFullYear()
let month = date.getMonth()+1
let day = date.getDate()
let m = month < 10 ? '0' + month : month
let d = day < 10 ? '0' + day : day
return `${year}/${m}/${d}`
}
beginTimeOfDay(date: Date){
let d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
return d.getTime()
}
}
let dateUtil = new DateUtil()
export default dateUtil as DateUtil
/**
*日期选择器页面
*/
.onChange((value: DatePickerResult) => {//用户选择日期传入
this.selectedDate.setFullYear(value.year, value.month, value.day)
})
/**
*数据统计主页
*/
//获取这一天的开始日期
@StorageProp('selectedDate') selectedDate: number = DateUtil.beginTimeOfDay(new Date())
controller: CustomDialogController = new CustomDialogController({
builder: DatePickDialog({selectedDate: new Date(this.selectedDate)})
})
// 1.日期信息
Row(){
//修改
Text(DateUtil.formatDate(this.selectedDate))
.fontColor($r('app.color.secondary_color'))
Image($r('app.media.ic_public_spinner'))
.width(20)
.fillColor($r('app.color.secondary_color'))
}
2.代码抽取
由于第三部分tips不一定有,所以可以不传入这个参数,tips?: string
//对重复部分进行抽取,文字说明,数字,补充说明(可以不传),传值,不传递引用
@Builder StatsBuilder($$:{label: string, value: number, tips?: string}){
Column({space: CommonConstants.SPACE_6}){
Text($$.label)//还可以吃
.fontColor($r('app.color.gray'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
Text($$.value.toFixed(0))
.fontSize(20)
.fontWeight(CommonConstants.FONT_WEIGHT_700)
if($$.tips){
Text($$.tips)
.fontSize(12)
.fontColor($r('app.color.light_gray'))
}
}
}
@Extend(Text) function grayText(){//通用样式
.fontSize(14)
.fontColor($r('app.color.light_gray'))
}
3.List列表
列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。
接口
List(value?:{space?: number | string, initialIndex?: number, scroller?: Scroller})
ListItem用来展示列表具体item,必须配合List来使用。
接口
ListItem(value?: string)
// xxx.ets
@Entry
@Component
struct ListItemExample {
private arr: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
build() {
Column() {
List({ space: 20, initialIndex: 0 }) {
ForEach(this.arr, (item) => {
ListItem() {
Text('' + item)
.width('100%').height(100).fontSize(16)
.textAlign(TextAlign.Center).borderRadius(10).backgroundColor(0xFFFFFF)
}
}, item => item)
}.width('90%')
}.width('100%').height('100%').backgroundColor(0xDCDCDC).padding({ top: 5 })
}
}
4.Tabs
通过页签进行内容视图切换的容器组件,每个页签对应一个内容视图
接口
Tabs(value?: {barPosition?: BarPosition, index?: number, controller?: TabsController})
// xxx.ets
@Entry
@Component
struct TabsExample {
@State fontColor: string = '#182431'
@State selectedFontColor: string = '#007DFF'
@State currentIndex: number = 0
private controller: TabsController = new TabsController()
@Builder TabBuilder(index: number, name: string) {
Column() {
Text(name)
.fontColor(this.currentIndex === index ? this.selectedFontColor : this.fontColor)
.fontSize(16)
.fontWeight(this.currentIndex === index ? 500 : 400)
.lineHeight(22)
.margin({ top: 17, bottom: 7 })
Divider()
.strokeWidth(2)
.color('#007DFF')
.opacity(this.currentIndex === index ? 1 : 0)
}.width('100%')
}
build() {
Column() {
Tabs({ barPosition: BarPosition.Start, controller: this.controller }) {
TabContent() {
Column().width('100%').height('100%').backgroundColor('#00CB87')
}.tabBar(this.TabBuilder(0, 'green'))
TabContent() {
Column().width('100%').height('100%').backgroundColor('#007DFF')
}.tabBar(this.TabBuilder(1, 'blue'))
}
.vertical(false)
.barMode(BarMode.Fixed)
.barWidth(360)
.barHeight(56)
.animationDuration(400)
.onChange((index: number) => {
this.currentIndex = index
})
.width(360)
.height(296)
.margin({ top: 52 })
.backgroundColor('#F1F3F5')
}.width('100%')
}
}