注:本人在创作过程中的疑难杂点,全以TODO Z-形式在代码中标注。设计所有页面的TODO会在结尾的注意事项中展示
一,Index-首页整体UI设计
1.1Tabs组件介绍
TabBar就是固定导航栏的部分
TabContent是上面需要展示的内容
1.2代码框架及其实现结果
@Entry
@Component
struct Index {
build() {
Tabs(){
//1.饮食记录
TabContent(){
Text('饮食记录页面')
}
.tabBar($r('app.string.tab_record'))
//2.发现页面
TabContent(){
Text('发现页面')
}
.tabBar($r('app.string.tab_discover'))
//3.我的主页
TabContent(){
Text('我的主页')
}
.tabBar($r('app.string.tab_user'))
}
.width('100%')
.height('100%')
}
}
1.3完善代码--改变bar位置及其样式和点击高亮功能
及其实现结果
Builder函数:自定义TabBar的样式
index参数:在TabBarBuilder中的参数index标记,实现点击不同的导航选项使其变色
import { CommonConstants } from '../common/constants/CommonConstants'
@Entry
@Component
struct Index {
@State currentIndex:number=0
//TODO Z2 设置bar样式是图片加文字
//TODO Z3.index—控制样式
@Builder TabBarBuilder(title:ResourceStr,image:ResourceStr,index:number){
Column({space:CommonConstants.SPACE_8}){
Image(image)
.width(22)
//判切换的角标跟样式角标一样的,蓝色,否则灰色
//TODO Z5.fillcolor的图片对象必须是svg格式
.fillColor(this.selectColor(index))
Text(title)
.fontSize(14)
.fontColor(this.selectColor(index))
}
}
/*高亮颜色设置函数*/
selectColor(index:number){
return this.currentIndex===index?$r('app.color.primary_color'):$r('app.color.gray')
}
build() {
//TODO Z1.bar(标题)的位置设置在下面
Tabs({barPosition:BarPosition.End}){
//1.饮食记录
TabContent(){
Text('饮食记录页面')
}
.tabBar(this.TabBarBuilder($r('app.string.tab_record'),$r('app.media.ic_calendar'),0))
//2.发现页面
TabContent(){
Text('发现页面')
}
.tabBar(this.TabBarBuilder($r('app.string.tab_discover'),$r('app.media.discover'),1))
//3.我的主页
TabContent(){
Text('我的主页')
}
.tabBar(this.TabBarBuilder($r('app.string.tab_user'),$r('app.media.ic_user_portrait'),2))
}
.width('100%')
.height('100%')
//TODO z4.记录当前切换的角标
.onChange(index=>
this.currentIndex=index
)
}
}
2.RecordIndex-页面内容UI
二,SearchHeader-顶部搜索栏
1.饮食记录UI
2.页面代码
//TODO Z1 seacher组件带图标;input不带图标
//TODO Z2.Badge容器型组件-显示数字角标
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct SeacherHeader {
build() {
Row({space:CommonConstants.SPACE_6}){
//带搜索图标的输入框
//TODO Z1 seacher组件带图标;input不带图标
Search({placeholder:'搜索饮食或运动信息'})
.textFont({size:18})
.layoutWeight(1)
//TODO Z2.Badge组件-显示数字角标
Badge({count:1,position:BadgePosition.RightTop,style:{fontSize:12}}){
Image($r('app.media.ic_public_email'))
.width(20)
}
}
.width(CommonConstants.THOUSANDTH_940)
}
}
三,StatsCard统计卡片
1.StatsCard-统计卡片UI:日期信息(自定义日期选择弹窗)+统计信息(滑动切换)
技术:日期选择窗~DatePicker;
穿梭切换~Swiper;
整体是一个列式的布局
build() {
Column(){
// 1.日期信息
Row(){
Text(...)
Image(...)
}
//点击启动弹窗
.onClick(() => this.controller.open())
// 2.统计信息
Swiper(){
// 2.1.热量统计
CalorieStats(...)
// 2.2.营养素统计
NutrientStats(...)
}
...
}
...
}
1.1.DatePickDialog--(自定义日期选择)弹窗 UI 和代码
//TODO Z1. DatePickDialog组件中的月份是0到11,在开发的过程中需要将月份加一
//TODO Z2. AppStorage.SetOrCreate全局存储;
存储数据时不要把日期存进去,否则在做日期监控的过程中会出现问题,应存储它所对应的毫秒值,用getTime去将日期时间转换为毫秒值。
在读取AppStorage的值时可以使用@StorageLink(双向绑定)或者@StorageProp(单向绑定)。
import { CommonConstants } from '../../common/constants/CommonConstants'
@CustomDialog
export default struct DatePickDialog {
controller: CustomDialogController
selectedDate: Date = new Date()
build() {
Column({space: CommonConstants.SPACE_12}){
// 1.日期选择器
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)
}
}
1.1.1DateUtil(class)
//TODO Z3. DateUtil组件--今天的开始日期;因为我们日期选择弹窗显示的日期只有年月日的零时零分零秒。
selectedDate中用getTime()来转换为毫秒值会精确到时分秒×
DateUtil组件--是今天的零时零分零秒√,为了在以后的日期判断时不出现错误(例如中间不到24小时)
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
1.2统计信息(滑动切换)UI和代码
Swiper: (1)CalorieStats-热量统计
(2)NutrientStats-营养素统计
//TODO Z1.Swiper-穿梭切换卡片的部分,可以为子组件提供滑动轮播显示的能力
// 2.统计信息
Swiper(){
// 2.1.热量统计
CalorieStats(...)
// 2.2.营养素统计
NutrientStats(...)
}
1.2.1CalorieStats--热量统计UI和代码
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct CaloriesStates {
intake: number=192
expend: number=150
//一般是根据身高体重计算,这里直接初始化
recommend: number = CommonConstants.RECOMMEND_CALORIE
//TODO Z3.还可以吃=recommend-摄入+消耗
remainCalorie() {
return this.recommend - this.intake + this.expend;
}
build() {
Row({ space: CommonConstants.SPACE_6 }) {
//1.饮食摄入
this.StatsBuilder('饮食摄入',this.intake)
//2.还可以吃多少
//TODO z2. stack重叠容器+progress进度条
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('还可以吃多少',this.remainCalorie(),`推荐${this.recommend}`)
}
//3.运动消耗
this.StatsBuilder('运动消耗',this.expend)
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding({ top: 30, bottom: 35 })
}
@Builder StatsBuilder(label:string, value :number ,tips?:string){
Column({space:6}) {
Text(label)
.fontColor($r('app.color.gray'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
//TODO Z1.text里是字符串,number转成整形字符串
Text(value.toFixed(0))
.fontSize(20)
.fontWeight(CommonConstants.FONT_WEIGHT_700)
if (tips) {
Text(tips)
.fontSize(12)
.fontColor($r('app.color.gray'))
}
}
}
}
1.2.2NutrientStats--营养素统计UI和代码
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct CaloriesStates {
carbon: number=23
protein: number=9
fat: number=7
recommendCarbon: number = CommonConstants.RECOMMEND_CARBON
recommendProtein: number = CommonConstants.RECOMMEND_PROTEIN
recommendFat: number = CommonConstants.RECOMMEND_FAT
build() {
Row({ space: CommonConstants.SPACE_6 }) {
this.StatsBuilder(
'碳水化合物',
this.carbon,
this.recommendCarbon,
$r('app.color.carbon_color')
)
this.StatsBuilder(
'蛋白质',
this.protein,
this.recommendProtein,
$r('app.color.protein_color')
)
this.StatsBuilder(
'脂肪',
this.fat,
this.recommendFat,
$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:6}) {
Stack(){
// 2.1.进度条
Progress({
value: value,
total: recommend,
type: ProgressType.Ring
})
.width(95)
.style({strokeWidth: CommonConstants.DEFAULT_8})
.color(color)
Column({space:6}){
Text('摄入推荐')
.fontSize(12)
.fontColor($r('app.color.gray'))
Text(`${value.toFixed(0)}/${recommend.toFixed(0)}`)
.fontSize(20)
.fontWeight(CommonConstants.FONT_WEIGHT_700)
}
}
Text(`${label}(克)`)
.fontSize(12)
.fontColor($r('app.color.gray'))
}
}
}
#注意事项
1.index-主页
2.ReardIndex-页面内容
2.1.SearcherHeader-头部导航
2.2StatsCard-卡片
2.2.1DatePickDialog-自定义日期弹窗
2.2.2swiper组件-切换器
2.2.2.1CaloriesStats-第一页
2.2.2.2NeuTrientStats-第二页
#结果
4.RecordList--记录列表UI和代码
// import router from '@ohos.router'
import { CommonConstants } from '../../common/constants/CommonConstants'
// 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'
//
//TODO Z1. 不属于通用样式,是TEXT文本单独样式,用extend(text)
@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}){
ForEach([1,2,3,4,5], (item) => {
ListItem(){
Column({space: CommonConstants.SPACE_8}){
// 1.分组的标题
Row({space: CommonConstants.SPACE_4}){
Image($r('app.media.ic_breakfast')).width(24)
Text('早餐').fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)
Text(`建议423~592.max}千卡`).grayText()
Blank()
Text('190').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'))
}
.width('100%')
// .onClick(() => {
// router.pushUrl({
// url: 'pages/ItemIndex',
// params: {type: group.type}
// })
// })
// 2.组内记录列表
List(){
ForEach([1,2], (item) => {
ListItem(){
Row({space: CommonConstants.SPACE_6}){
Image($r('app.media.toast')).width(50)
Column({space: CommonConstants.SPACE_4}){
Text('全麦吐司').fontWeight(CommonConstants.FONT_WEIGHT_500)
Text('1片').grayText()
}
Blank()
Text(`91千卡`).grayText()
}
.width('100%')
.padding(CommonConstants.SPACE_6)
}
//TODO Z3. 侧滑删除食物
.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%')
.margin({top: 10})
}
//TODO Z2.删除定义样式
@Builder deleteButton(){
Image($r('app.media.ic_public_delete_filled'))
.width(20)
.fillColor(Color.Red)
.margin(5)
}
}
四,ItemIndex--食物列表
1.ItemIndex-食物列表主页 UI 和代码:
1.1头部导航--Header(@Builder函数)
1.2列表--ItemList
1.3底部面板--Panel
1.3.1顶部日期--ItemPanelHeader
1.3.2记录项卡片--ItemCard
1.3.3数字键盘--NumberKeyboard
1.3.4按钮--PanelButton(@Builder函数)
列式布局的表单,最上方是一个导航条、其下面的每个卡片包含文本、图片、按钮等。
点击导航条上不同的导航项时会显示不同的页面--Tabs组件,下面的卡片内容用List来渲染
1.1Header--头部导航-UI和代码(@Builder函数)
1.2.ItemList-食物列表页-UI及其代码
// import { CommonConstants } from '../../common/constants/CommonConstants'
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()
}
.tabBar('全部')
TabContent() {
this.TabContentBuilder()
}
.tabBar('主食')
TabContent() {
this.TabContentBuilder()
}
.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%')
//.barMode(BarMode.Scrollable)
}
@Builder TabContentBuilder() {
List({space:CommonConstants.SPACE_6}) {
ForEach([1, 2, 3, 4, 5, 6], (item) => {
ListItem() {
Row({ space: CommonConstants.SPACE_6 }) {
Image($r('app.media.toast')).width(50)
Column({ space: CommonConstants.SPACE_4 }) {
Text('全麦吐司').fontWeight(CommonConstants.FONT_WEIGHT_500)
Text('91千卡/片').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)
}
})
}
.width('100%')
.height('100%')
}
// 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'))
// }.alignItems(HorizontalAlign.Start)
//
// 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.3Panel--底部面板-UI和代码)(在ItemList添加点击事件
//TODO z1.设函数:达到子控制父,父调用时覆盖)
Panel组件:一个可滑动面板,提供一种轻量的内容展示窗口,方便在不同尺寸中切换
注意Panel它所在容器的高度以及内元素的高度必须是一个固定的
1.3.1 顶部日期--ItemPanelHeader-UI和代码
1.3.2 记录项卡片--ItemCard-UI和代
import { CommonConstants } from '../../common/constants/CommonConstants'
import RecordItem from '../../viewmodel/RecordItem'
@Component
export default struct ItemCard {
//TODO Z3. 键盘不是本组件内,键盘修改,这里接受用PROP;Prop不需要初始化
@Prop amount: number
// @Link item: RecordItem
build() {
Column({space: CommonConstants.SPACE_8}){
// 1.图片
Image($r('app.media.toast')).width(150)
// 2.名称
Row(){
Text('全麦吐司').fontWeight(CommonConstants.FONT_WEIGHT_700)
}
.backgroundColor($r('app.color.lightest_primary_color'))
.padding({top: 5, bottom: 5, left: 12, right: 12})
//TODO Z1. 下划线
Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)//透明度
// 3.营养素
Row({space: CommonConstants.SPACE_8}){
this.NutrientInfo('热量(千卡)', 91.0)
this.NutrientInfo('碳水(克)', 15.5)
this.NutrientInfo('蛋白质(克)', 4.4)
this.NutrientInfo('脂肪(克)', 1.3)
}
Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)
// 4.数量
Row(){
Column({space: CommonConstants.SPACE_4}){
Text(this.amount.toFixed(1))
.fontSize(50).fontColor($r('app.color.primary_color'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
Divider().color($r('app.color.primary_color'))
}
.width(150)
Text('片')
.fontColor($r('app.color.light_gray'))
.fontWeight(CommonConstants.FONT_WEIGHT_600)
}
}
}
@Builder NutrientInfo(label: string, value: number){
Column({space: CommonConstants.SPACE_8}){
Text(label).fontSize(14).fontColor($r('app.color.light_gray'))
//TODO Z2.toFix(1)转成1位小数
Text(value.toFixed(1)).fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)
//(value * this.amount)
}
}
}
#注意事项
1.ItemList
2.ItemPanelHeader
3.ItemCard
4.ItemIndex
1.3.3 NumberKeyboard--数字键盘-UI和代码
import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct NumberKeyboard {
numbers: string[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '.']
@Link amount: number
@Link value: string
@Styles keyBoxStyle(){
.backgroundColor(Color.White)
.borderRadius(8)
.height(60)
}
build() {
//TODO Z1. 网格容器
Grid(){
ForEach(this.numbers, num => {
/*按钮.~9*/
GridItem(){
Text(num).fontSize(20).fontWeight(CommonConstants.FONT_WEIGHT_900)
}
.keyBoxStyle()
.onClick(() => this.clickNumber(num))
})
/*删除*/
GridItem(){
Text('删除').fontSize(20).fontWeight(CommonConstants.FONT_WEIGHT_900)
}
.keyBoxStyle()
.onClick(() => this.clickDelete())
}
.width('100%')
//TODO Z3. 参考元素数据设置4*行高(60)+5*行距(8)=240+40=280
.height(280)
.backgroundColor($r('app.color.index_page_background'))
//TODO Z2. 只设置列,动态布局;设置列和行,固定布局,多出来的不显示;
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8)//列间距
.rowsGap(8)
.padding(8)
.margin({top: 10})
}
/*键盘转换到卡片,校验格式*/
clickNumber(num: string){
// 1.拼接用户输入的内容
let val = this.value + num
// 2.校验输入格式是否正确(俩小数点/小数点位数=2)
let firstIndex = val.indexOf('.')//从前往后数第一个.位置
let lastIndex = val.lastIndexOf('.')//从后往前数第一个.位置
//if有两个小数点/一个小数点,小数位数超过两位
if(firstIndex !== lastIndex || (lastIndex != -1 && lastIndex < val.length - 2)){
// 非法输入
return
}
// 3.将字符串转为数值
let amount = this.parseFloat(val)
// 4.保存;设置上限
if(amount >= 999.9){
this.amount = 999.0
this.value = '999'
}else{
this.amount = amount
this.value = val
}
}
/*键盘删除*/
clickDelete(){
if(this.value.length <= 0){
this.value = ''
this.amount = 0
return
}
this.value = this.value.substring(0, this.value.length - 1)
this.amount = this.parseFloat(this.value)
}
/*string转number AND 校验格式 */
parseFloat(str: string){
//TODO Z4. 删完了变成0
if(!str){
return 0
}
//若最后一位是".",
if(str.endsWith('.')){
//提取字符串中介于两个指定索引之间的字符。
str = str.substring(0, str.length - 1)
}
//string转number
return parseFloat(str)
}
}
1.3.4PanelButton(@Builder函数)--按钮-UI和代码
#注意事项
1.ItemIndex
2.NumberKeyboard