本文解析 ImportantDays 项目中年视图的两个组件:
YearCalendar负责整体年份网格布局和无限滚动,BaseCalendar负责单月小日历的渲染和重要日标记。两个组件协作呈现完整的年视图日历体验。
一、年视图整体设计
年视图将一整年的 12 个月以小日历网格的形式展示,用户可以快速浏览全年概况,并通过颜色圆点识别哪些日期有重要日。
组件协作关系
CalendarPage └── YearCalendar (年视图容器) ├── 年份标题 └── Swiper + LazyForEach └── Grid (3×4 月份网格) └── BaseCalendar × 12 (单月小日历)二、YearCalendar 组件
组件定义
@ComponentV2exportstruct YearCalendar{@Paramvm:CalendarVM=CalendarVM.getInstance();@ParamonSelectDate:(date:Date)=>void=()=>{};@LocalyearDataSource:YearDataSource=newYearDataSource();@LocalcurrentYear:number=newDate().getFullYear();privateswiperController:SwiperController=newSwiperController();}参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
vm | CalendarVM | 日历视图模型(通过 @Param 传入) |
onSelectDate | (date: Date) => void | 日期选择回调 |
yearDataSource | YearDataSource | 年份数据源(@Local 内部状态) |
currentYear | number | 当前显示年份 |
swiperController | SwiperController | Swiper 控制器 |
初始化
aboutToAppear():void{this.yearDataSource=this.vm.yearDataSource;this.currentYear=this.vm.selectDate.year();}在组件即将出现时,从 VM 获取数据源和当前年份。
build 方法
build(){Column(){// 年份标题Row(){Text(`${this.currentYear}年`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')}.width('100%').justifyContent(FlexAlign.Center).padding({top:8,bottom:12})// 月份网格 - Swiper + LazyForEachSwiper(this.swiperController){LazyForEach(this.yearDataSource,(year:number)=>{Grid(){ForEach([0,1,2,3,4,5,6,7,8,9,10,11],(month:number)=>{GridItem(){BaseCalendar({year:year,month:month,startWeekday:this.vm.startWeekday})}},(month:number)=>`${year}_${month}`)}.columnsTemplate('1fr 1fr 1fr').rowsGap(8).columnsGap(4).width('100%').height('100%').padding(4)},(year:number)=>`year_${year}`)}.cachedCount(CALENDAR_SWIPER_COUNT).index(1).loop(false).indicator(false).onChange((index:number)=>{this.handleYearSwiperChange(index);}).layoutWeight(1)}.width('100%').height('100%')}布局结构
Column ├── Row (年份标题) └── Swiper └── LazyForEach └── Grid (3列) └── GridItem × 12 └── BaseCalendarGrid 配置
- 3 列 4 行:
columnsTemplate('1fr 1fr 1fr')— 每行 3 个月,共 4 行 - 行间距:
rowsGap(8)— 月份之间的垂直间距 - 列间距:
columnsGap(4)— 月份之间的水平间距
月份网格布局
┌────────┬────────┬────────┐ │ 1月 │ 2月 │ 3月 │ ├────────┼────────┼────────┤ │ 4月 │ 5月 │ 6月 │ ├────────┼────────┼────────┤ │ 7月 │ 8月 │ 9月 │ ├────────┼────────┼────────┤ │ 10月 │ 11月 │ 12月 │ └────────┴────────┴────────┘三、年视图无限滚动
privatehandleYearSwiperChange(index:number):void{if(index===this.yearDataSource.totalCount()-1){// 追加下一年constlastYear=this.yearDataSource.getData(this.yearDataSource.totalCount()-1);this.yearDataSource.addData(lastYear+1);}elseif(index===0){// 前插上一年constfirstYear=this.yearDataSource.getData(0);this.yearDataSource.addDataFromHead(firstYear-1);}constcurrentYear=this.yearDataSource.getData(index);this.currentYear=currentYear;this.vm.selectDate=this.vm.selectDate.year(currentYear);}滚动逻辑
- 滑到末尾:追加下一年(
lastYear + 1) - 滑到开头:前插上一年(
firstYear - 1) - 更新状态:同步
currentYear和 VM 的selectDate
滚动示例
初始: [2025] [2026*] [2027] 滑到2027: [2025] [2026] [2027*] → 追加2028 → [2025] [2026] [2027*] [2028]四、BaseCalendar 组件
BaseCalendar负责渲染单月的小日历,是年视图中每个月份的独立单元。
组件定义
@Componentexportstruct BaseCalendar{@Propyear:number=0;@Propmonth:number=0;@PropstartWeekday:number=0;privatevm:CalendarVM=CalendarVM.getInstance();privatemanager:ImportantDayManager=ImportantDayManager.getInstance();}注意:使用 V1 装饰器
BaseCalendar使用@Component和@Prop(V1),而非@ComponentV2和@Param(V2)。这可能是因为:
@Prop在 V1 中是值拷贝,适合简单类型参数- 小日历组件不需要响应式更新(年视图切换时整体重渲染)
- 保持与可能存在的 V1 组件兼容
build 方法
build(){Column(){// 月份标题Text(`${this.month+1}月`).fontSize(14).fontWeight(FontWeight.Medium).fontColor('#333333').margin({bottom:4})// 星期标题行Row(){ForEach(WEEK_NAMES,(name:string)=>{Text(name).fontSize(10).fontColor('#999999').layoutWeight(1).textAlign(TextAlign.Center)})}.width('100%')// 日期网格Grid(){ForEach(this.getDateList(),(dateModel:DateModel,index:number)=>{GridItem(){this.dayItem(dateModel)}},(dateModel:DateModel,index:number)=>`${this.year}_${this.month}_${index}`)}.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr').rowsGap(2).width('100%')}.width('100%').padding(8)}布局结构
Column ├── Text (月份标题,如 "3月") ├── Row (星期标题行) └── Grid (7列) └── GridItem × 42 └── dayItem与 MonthWeekCalendar 的差异
| 特性 | MonthWeekCalendar | BaseCalendar |
|---|---|---|
| 装饰器 | @ComponentV2 | @Component |
| 参数传递 | @Param(引用) | @Prop(值拷贝) |
| 字体大小 | 16vp | 10-14vp |
| 日期项高度 | 52vp | 22vp |
| 选中背景 | 有(Circle 填充) | 无 |
| 重要日标记 | 圆点 + 标题预览 | 仅圆点 |
| 点击交互 | 有 | 无 |
五、日期列表生成
privategetDateList():DateModel[]{constdate=dayjs().year(this.year).month(this.month);constlist:DateModel[]=[];constfirstDay=date.date(1);constdaysInMonth=date.daysInMonth();letfirstDayWeekday:number=firstDay.day();firstDayWeekday=(firstDayWeekday-this.startWeekday+WEEK_DAYS)%WEEK_DAYS;// 上月填充for(leti=firstDayWeekday-1;i>=0;i--){constprevDate=firstDay.subtract(i+1,'day');list.push(newDateModel(prevDate,false));}// 当月for(leti=1;i<=daysInMonth;i++){constdayDate=date.date(i);list.push(newDateModel(dayDate,true));}// 下月填充while(list.length<42){constlastDate=list[list.length-1].dayjsObj;list.push(newDateModel(lastDate.add(1,'day'),false));}returnlist;}此方法与CalendarVM.getDateList()逻辑相同,但BaseCalendar独立实现以减少对 VM 的依赖。
六、日期项渲染
@BuilderdayItem(dateModel:DateModel){Stack({alignContent:Alignment.Center}){// 重要日标记点if(this.hasImportantDay(dateModel)){Circle({width:4,height:4}).fill(this.getFirstDayColor(dateModel)).position({x:'50%',y:2})}Text(`${dateModel.day}`).fontSize(11).fontColor(this.getDayColor(dateModel))}.width('100%').height(22)}标记点位置
.position({x:'50%',y:2})重要日标记圆点定位在日期格的顶部中央,4×4vp 的小圆点不影响日期数字的显示。
日期颜色策略
privategetDayColor(dateModel:DateModel):string{if(!dateModel.isCurrentMonth){return'#CCCCCC';// 非当月 → 灰色}if(this.vm.isToday(dateModel.dayjsObj)){return'#FF6B6B';// 今天 → 红色}if(this.hasImportantDay(dateModel)){returnthis.getFirstDayColor(dateModel);// 有重要日 → 重要日颜色}return'#333333';// 普通 → 深灰}颜色优先级
非当月 → 今天 → 有重要日 → 普通注意:年视图没有"选中"状态,因为年视图主要用于浏览,选中操作在月视图中进行。
七、重要日查询
privatehasImportantDay(dateModel:DateModel):boolean{returnthis.manager.hasImportantDay(dateModel.dateStr);}privategetFirstDayColor(dateModel:DateModel):string{constdays=this.manager.getDaysByDate(dateModel.dateStr);if(days.length>0){returndays[0].color;}return'#FF6B6B';}与MonthWeekCalendar相同,通过ImportantDayManager的dayMap查询。
八、从年视图到月视图的切换
在CalendarPage中,年视图选择日期后会切换到月视图:
YearCalendar({vm:this.vm,onSelectDate:(date:Date)=>{this.vm.changeDate(dayjs(date));this.vm.changeViewType(ViewTypeEnum.MONTH);this.updateTitle();this.updateSelectedDays();}})切换流程
用户在年视图中点击某月 ↓ BaseCalendar 的小日历不响应点击 (但 Grid 的整体可以扩展此功能) ↓ 实际上年视图通过 onSelectDate 回调 ↓ VM 切换到月视图 ↓ CalendarPage 重新渲染为月视图九、性能分析
渲染开销
年视图一次渲染 12 个月的小日历,每个小日历有 42 个日期格:
- 总日期格数:12 × 42 = 504 个
- 每个日期格:1 个 Text + 可能 1 个 Circle
- DOM 节点数:约 1000+
性能优化
- LazyForEach:年视图的 Swiper 只渲染可见页,预渲染页通过
cachedCount控制 - 小字体小尺寸:10-11vp 字体、4vp 圆点,减少渲染像素
- 无交互:小日历不响应点击,减少事件处理开销
- Map 查询:
dayMap提供 O(1) 查询
十、总结
YearCalendar和BaseCalendar的协作展示了组件分层设计:
- YearCalendar:管理年份级别的 Swiper 滚动和 Grid 布局
- BaseCalendar:专注于单月小日历的渲染和标记
- 职责分离:容器组件管布局和状态,叶子组件管渲染
- 性能可控:LazyForEach + 小尺寸渲染确保流畅
- V1/V2 混用:展示了 ArkUI V1 和 V2 组件可以共存
年视图为用户提供了全年重要日的鸟瞰视角,是月视图的有力补充。