鸿蒙OS&UniApp 实现一个精致的日历组件#三方框架 #Uniapp

使用 UniApp 实现一个精致的日历组件

前言

最近在开发一个约会小程序时,需要实现一个既美观又实用的日历组件。市面上虽然有不少现成的组件库,但都不太符合我们的设计需求。于是,我决定从零开始,基于 UniApp 自己实现一个功能完善、UI精致的日历组件。本文将分享我的实现思路和过程,希望对你有所帮助。

需求分析

首先,让我们明确一下这个日历组件需要满足的需求:

  1. 日历展示:能够清晰展示年、月、日信息
  2. 日期选择:支持单选、范围选择功能
  3. 样式定制:支持自定义主题颜色、特殊日期标记
  4. 事件标记:能够在特定日期显示事件标记或小红点
  5. 手势操作:支持左右滑动切换月份
  6. 农历展示:可选择性展示农历信息

基于以上需求,我们开始设计和编码。

实现思路

1. 数据结构设计

日历组件的核心是对日期数据的管理。我们需要设计一个合理的数据结构来存储和操作日期信息。

javascript 复制代码
{
  year: 2023,          // 当前显示的年份
  month: 5,            // 当前显示的月份(1-12)
  day: 15,             // 当前选中的日期
  weeks: [             // 按周分组的日期数据
    [                  // 第一周
      {
        day: 30,       // 日期
        month: 4,      // 所属月份
        isCurrentMonth: false,  // 是否属于当前月
        isToday: false,         // 是否是今天
        isSelected: false,      // 是否被选中
        hasEvent: false,        // 是否有事件
        lunar: '四月初一',      // 农历信息
        disable: false          // 是否禁用
      },
      // ... 其他日期数据
    ],
    // ... 其他周数据
  ]
}

2. 核心功能实现

首先,我们需要计算出日历网格中的所有日期数据。这包括当前月的所有日期,以及为了填满网格而需要显示的上个月和下个月的部分日期。

以下是生成日历数据的核心函数:

javascript 复制代码
/**
 * 生成日历数据
 * @param {Number} year 年份
 * @param {Number} month 月份(1-12)
 * @return {Array} 日历数据数组
 */
function generateCalendarData(year, month) {
  // 获取当前月第一天是星期几
  const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
  
  // 获取当前月的天数
  const daysInMonth = new Date(year, month, 0).getDate();
  
  // 获取上个月的天数
  const daysInPrevMonth = new Date(year, month - 1, 0).getDate();
  
  // 获取今天的日期信息
  const today = new Date();
  const isToday = (date) => {
    return date.getFullYear() === today.getFullYear() 
      && date.getMonth() === today.getMonth() 
      && date.getDate() === today.getDate();
  };
  
  // 日历数据数组
  let days = [];
  
  // 添加上个月的日期
  for (let i = 0; i < firstDayOfMonth; i++) {
    const prevDay = daysInPrevMonth - firstDayOfMonth + i + 1;
    const prevMonth = month - 1 > 0 ? month - 1 : 12;
    const prevYear = prevMonth === 12 ? year - 1 : year;
    
    days.push({
      day: prevDay,
      month: prevMonth,
      year: prevYear,
      isCurrentMonth: false,
      isToday: isToday(new Date(prevYear, prevMonth - 1, prevDay)),
      isSelected: false,
      hasEvent: false,  // 根据实际情况设置
      lunar: getLunarDate(prevYear, prevMonth, prevDay),  // 获取农历日期
      disable: false
    });
  }
  
  // 添加当前月的日期
  for (let i = 1; i <= daysInMonth; i++) {
    days.push({
      day: i,
      month,
      year,
      isCurrentMonth: true,
      isToday: isToday(new Date(year, month - 1, i)),
      isSelected: false,
      hasEvent: false,  // 根据实际情况设置
      lunar: getLunarDate(year, month, i),  // 获取农历日期
      disable: false
    });
  }
  
  // 添加下个月的日期,补满 6 行
  const totalDays = 42;  // 6行7列
  const remainingDays = totalDays - days.length;
  
  for (let i = 1; i <= remainingDays; i++) {
    const nextMonth = month + 1 <= 12 ? month + 1 : 1;
    const nextYear = nextMonth === 1 ? year + 1 : year;
    
    days.push({
      day: i,
      month: nextMonth,
      year: nextYear,
      isCurrentMonth: false,
      isToday: isToday(new Date(nextYear, nextMonth - 1, i)),
      isSelected: false,
      hasEvent: false,  // 根据实际情况设置
      lunar: getLunarDate(nextYear, nextMonth, i),  // 获取农历日期
      disable: false
    });
  }
  
  // 将日期按周分组
  const weeks = [];
  for (let i = 0; i < days.length; i += 7) {
    weeks.push(days.slice(i, i + 7));
  }
  
  return weeks;
}

3. 组件开发

接下来,让我们使用 UniApp 开发这个日历组件。我们将创建一个独立的组件,以便在不同项目中复用。

vue 复制代码
<!-- calendar.vue -->
<template>
  <view class="calendar">
    <!-- 日历头部 -->
    <view class="calendar-header">
      <view class="calendar-title">
        <text>{{ year }}年{{ month }}月</text>
      </view>
      <view class="calendar-controls">
        <view class="prev-month" @click="changeMonth(-1)">
          <text class="iconfont icon-left"></text>
        </view>
        <view class="next-month" @click="changeMonth(1)">
          <text class="iconfont icon-right"></text>
        </view>
      </view>
    </view>
    
    <!-- 星期栏 -->
    <view class="calendar-weeks">
      <view class="week-item" v-for="(item, index) in weekDays" :key="index">
        <text :class="{'weekend': index === 0 || index === 6}">{{ item }}</text>
      </view>
    </view>
    
    <!-- 日期网格 -->
    <view class="calendar-days">
      <view class="calendar-week" v-for="(week, weekIndex) in weeks" :key="weekIndex">
        <view
          class="day-item"
          v-for="(day, dayIndex) in week"
          :key="dayIndex"
          :class="{
            'current-month': day.isCurrentMonth,
            'other-month': !day.isCurrentMonth,
            'today': day.isToday,
            'selected': day.isSelected,
            'disabled': day.disable
          }"
          @click="selectDay(day)"
        >
          <view class="day-number">{{ day.day }}</view>
          <view class="lunar-date" v-if="showLunar">{{ day.lunar }}</view>
          <view class="event-dot" v-if="day.hasEvent"></view>
        </view>
      </view>
    </view>
    
    <!-- 底部操作栏 -->
    <view class="calendar-footer" v-if="showFooter">
      <view class="btn-today" @click="goToToday">今天</view>
      <view class="btn-clear" @click="clearSelection">清除</view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'Calendar',
  props: {
    // 默认选中日期
    value: {
      type: [Date, Array],
      default: null
    },
    // 是否多选
    multiple: {
      type: Boolean,
      default: false
    },
    // 是否显示农历
    showLunar: {
      type: Boolean,
      default: true
    },
    // 是否显示底部操作栏
    showFooter: {
      type: Boolean,
      default: true
    },
    // 特殊日期,包含事件的日期
    events: {
      type: Array,
      default: () => []
    },
    // 主题色
    themeColor: {
      type: String,
      default: '#2979ff'
    }
  },
  data() {
    return {
      year: new Date().getFullYear(),
      month: new Date().getMonth() + 1,
      weeks: [],
      weekDays: ['日', '一', '二', '三', '四', '五', '六'],
      selectedDays: []
    };
  },
  watch: {
    value: {
      handler(val) {
        this.initSelection(val);
      },
      immediate: true
    },
    events: {
      handler() {
        this.updateCalendar();
      }
    }
  },
  created() {
    this.updateCalendar();
  },
  methods: {
    // 更新日历数据
    updateCalendar() {
      this.weeks = this.generateCalendarData(this.year, this.month);
      this.markEvents();
      this.initSelection(this.value);
    },
    
    // 生成日历数据
    generateCalendarData(year, month) {
      // 实现与上文相同的函数
      // ...
    },
    
    // 切换月份
    changeMonth(offset) {
      let newMonth = this.month + offset;
      let newYear = this.year;
      
      if (newMonth < 1) {
        newMonth = 12;
        newYear--;
      } else if (newMonth > 12) {
        newMonth = 1;
        newYear++;
      }
      
      this.year = newYear;
      this.month = newMonth;
      this.updateCalendar();
    },
    
    // 选择日期
    selectDay(day) {
      if (day.disable) return;
      
      if (this.multiple) {
        const index = this.selectedDays.findIndex(d => 
          d.year === day.year && d.month === day.month && d.day === day.day
        );
        
        if (index === -1) {
          // 添加选中日期
          this.selectedDays.push({
            year: day.year,
            month: day.month,
            day: day.day
          });
        } else {
          // 取消选中
          this.selectedDays.splice(index, 1);
        }
      } else {
        // 单选模式
        this.selectedDays = [{
          year: day.year,
          month: day.month,
          day: day.day
        }];
      }
      
      // 更新选中状态
      this.updateSelectedStatus();
      
      // 触发事件
      this.$emit('input', this.getSelectedDates());
      this.$emit('change', this.getSelectedDates());
    },
    
    // 初始化选中状态
    initSelection(value) {
      if (!value) {
        this.selectedDays = [];
      } else if (Array.isArray(value)) {
        this.selectedDays = value.map(date => ({
          year: date.getFullYear(),
          month: date.getMonth() + 1,
          day: date.getDate()
        }));
      } else if (value instanceof Date) {
        this.selectedDays = [{
          year: value.getFullYear(),
          month: value.getMonth() + 1,
          day: value.getDate()
        }];
      }
      
      this.updateSelectedStatus();
    },
    
    // 更新选中状态
    updateSelectedStatus() {
      this.weeks = this.weeks.map(week => {
        return week.map(day => {
          const isSelected = this.selectedDays.some(d => 
            d.year === day.year && d.month === day.month && d.day === day.day
          );
          
          return { ...day, isSelected };
        });
      });
    },
    
    // 标记事件
    markEvents() {
      if (!this.events || !this.events.length) return;
      
      this.weeks = this.weeks.map(week => {
        return week.map(day => {
          const hasEvent = this.events.some(event => {
            const eventDate = new Date(event.date);
            return eventDate.getFullYear() === day.year &&
                  eventDate.getMonth() + 1 === day.month &&
                  eventDate.getDate() === day.day;
          });
          
          return { ...day, hasEvent };
        });
      });
    },
    
    // 返回选中的日期对象
    getSelectedDates() {
      const dates = this.selectedDays.map(d => {
        return new Date(d.year, d.month - 1, d.day);
      });
      
      return this.multiple ? dates : dates[0] || null;
    },
    
    // 跳转到今天
    goToToday() {
      const today = new Date();
      this.year = today.getFullYear();
      this.month = today.getMonth() + 1;
      this.updateCalendar();
    },
    
    // 清除选择
    clearSelection() {
      this.selectedDays = [];
      this.updateSelectedStatus();
      this.$emit('input', this.multiple ? [] : null);
      this.$emit('change', this.multiple ? [] : null);
    }
  }
};
</script>

<style lang="scss">
.calendar {
  background-color: #fff;
  border-radius: 12rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
  padding: 20rpx;
  
  .calendar-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20rpx;
    
    .calendar-title {
      font-size: 32rpx;
      font-weight: bold;
    }
    
    .calendar-controls {
      display: flex;
      
      .prev-month, .next-month {
        width: 60rpx;
        height: 60rpx;
        display: flex;
        justify-content: center;
        align-items: center;
        
        .iconfont {
          font-size: 28rpx;
          color: #666;
        }
      }
    }
  }
  
  .calendar-weeks {
    display: flex;
    border-bottom: 1px solid #f0f0f0;
    padding-bottom: 10rpx;
    
    .week-item {
      flex: 1;
      text-align: center;
      font-size: 28rpx;
      color: #999;
      
      .weekend {
        color: #ff5252;
      }
    }
  }
  
  .calendar-days {
    .calendar-week {
      display: flex;
      margin-top: 10rpx;
      
      .day-item {
        flex: 1;
        height: 80rpx;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        position: relative;
        border-radius: 8rpx;
        
        &.current-month {
          color: #333;
        }
        
        &.other-month {
          color: #ccc;
        }
        
        &.today {
          background-color: rgba(41, 121, 255, 0.1);
          
          .day-number {
            font-weight: bold;
          }
        }
        
        &.selected {
          background-color: v-bind(themeColor);
          color: #fff;
          
          .lunar-date {
            color: rgba(255, 255, 255, 0.8);
          }
        }
        
        &.disabled {
          color: #ddd;
          cursor: not-allowed;
        }
        
        .day-number {
          font-size: 28rpx;
        }
        
        .lunar-date {
          font-size: 20rpx;
          color: #999;
          margin-top: 4rpx;
        }
        
        .event-dot {
          position: absolute;
          width: 8rpx;
          height: 8rpx;
          border-radius: 50%;
          background-color: #ff5252;
          bottom: 6rpx;
        }
      }
    }
  }
  
  .calendar-footer {
    display: flex;
    justify-content: flex-end;
    margin-top: 20rpx;
    
    .btn-today, .btn-clear {
      padding: 6rpx 20rpx;
      font-size: 24rpx;
      border-radius: 4rpx;
      margin-left: 20rpx;
    }
    
    .btn-today {
      background-color: v-bind(themeColor);
      color: #fff;
    }
    
    .btn-clear {
      border: 1px solid #ddd;
      color: #666;
    }
  }
}
</style>

农历计算

对于农历的计算,我们可以使用第三方库,比如 lunar-calendar,也可以自己实现。以下是一个简化版的农历计算函数:

javascript 复制代码
function getLunarDate(year, month, day) {
  // 实际项目中建议使用成熟的农历库
  // 这里只做简单演示
  const lunarInfo = [
    0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
    // ... 更多农历数据
  ];
  
  // 简化版的农历计算,实际项目中请使用完整实现
  const lunarDay = ['初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
                   '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
                   '廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'];
  
  // 模拟计算农历日期,实际使用中请使用准确算法
  const dayIndex = (year * 10000 + month * 100 + day) % 30;
  return lunarDay[dayIndex];
}

实际使用示例

下面是在实际项目中使用这个日历组件的示例:

vue 复制代码
<template>
  <view class="container">
    <view class="header">
      <text class="title">我的日程</text>
    </view>
    
    <view class="calendar-wrapper">
      <Calendar
        v-model="selectedDate"
        :events="eventList"
        themeColor="#42b983"
        @change="onDateChange"
      />
    </view>
    
    <view class="event-list">
      <view class="list-title">
        <text>{{ formatDate(selectedDate) }}的日程</text>
      </view>
      
      <view class="empty-tip" v-if="!todayEvents.length">
        <text>暂无日程安排</text>
      </view>
      
      <view class="event-item" v-for="(event, index) in todayEvents" :key="index">
        <view class="event-time">{{ event.time }}</view>
        <view class="event-content">
          <text class="event-title">{{ event.title }}</text>
          <text class="event-desc">{{ event.description }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import Calendar from '@/components/calendar/calendar.vue';

export default {
  components: {
    Calendar
  },
  data() {
    return {
      selectedDate: new Date(),
      eventList: [
        {
          date: '2023-05-15',
          title: '项目会议',
          time: '10:00',
          description: '讨论新功能开发计划'
        },
        {
          date: '2023-05-18',
          title: '团队建设',
          time: '14:00',
          description: '外出活动'
        },
        {
          date: '2023-05-22',
          title: '产品发布',
          time: '09:30',
          description: '新版本上线'
        }
      ]
    };
  },
  computed: {
    todayEvents() {
      if (!this.selectedDate) return [];
      
      const dateStr = this.formatDate(this.selectedDate);
      return this.eventList.filter(event => event.date === dateStr);
    }
  },
  methods: {
    onDateChange(date) {
      console.log('选中日期变化:', date);
    },
    
    formatDate(date) {
      if (!date) return '';
      
      const year = date.getFullYear();
      const month = (date.getMonth() + 1).toString().padStart(2, '0');
      const day = date.getDate().toString().padStart(2, '0');
      
      return `${year}-${month}-${day}`;
    }
  }
};
</script>

<style lang="scss">
.container {
  padding: 30rpx;
  
  .header {
    margin-bottom: 30rpx;
    
    .title {
      font-size: 36rpx;
      font-weight: bold;
    }
  }
  
  .calendar-wrapper {
    margin-bottom: 40rpx;
  }
  
  .event-list {
    background-color: #fff;
    border-radius: 12rpx;
    padding: 20rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
    
    .list-title {
      font-size: 30rpx;
      font-weight: bold;
      margin-bottom: 20rpx;
      color: #333;
    }
    
    .empty-tip {
      text-align: center;
      padding: 40rpx 0;
      color: #999;
    }
    
    .event-item {
      display: flex;
      padding: 20rpx 0;
      border-bottom: 1px solid #f5f5f5;
      
      &:last-child {
        border-bottom: none;
      }
      
      .event-time {
        width: 120rpx;
        color: #666;
      }
      
      .event-content {
        flex: 1;
        
        .event-title {
          font-size: 28rpx;
          color: #333;
          margin-bottom: 6rpx;
        }
        
        .event-desc {
          font-size: 24rpx;
          color: #999;
        }
      }
    }
  }
}
</style>

适配鸿蒙系统

随着华为鸿蒙系统的普及,我们也需要考虑在鸿蒙系统上的兼容性。好消息是,UniApp已经开始支持鸿蒙系统的开发。要让我们的日历组件更好地适配鸿蒙系统,可以考虑以下几点:

  1. 遵循鸿蒙设计规范:鸿蒙系统有自己的设计语言和规范,包括字体、颜色、圆角等。我们可以根据鸿蒙的设计规范调整组件样式。

  2. 性能优化:鸿蒙系统注重流畅性和低功耗,我们可以减少不必要的渲染和计算,优化日历组件的性能。

  3. 手势适配:确保日历组件的滑动等手势操作在鸿蒙系统上响应流畅。

  4. 分辨率适配:鸿蒙设备的分辨率可能有所不同,确保组件在各种分辨率下都能正常显示。

  5. 权限处理:如果日历组件需要访问系统日历数据,需要适配鸿蒙的权限管理机制。

总结与思考

通过本文,我们从零开始实现了一个功能丰富、外观精致的日历组件。这个组件具有以下特点:

  1. 灵活的日期选择:支持单选和多选模式
  2. 农历显示:可选择性显示农历信息
  3. 事件标记:能够在特定日期显示事件标记
  4. 自定义主题:支持自定义主题颜色
  5. 手势操作:支持左右滑动切换月份

当然,这个日历组件还可以进一步优化和扩展,比如:

  • 添加周视图、月视图、年视图切换功能
  • 支持日程管理,添加、编辑、删除日程
  • 增加日程提醒功能
  • 支持农历节日、法定节假日标记
  • 优化性能,减少不必要的重新渲染

希望这篇文章对你在 UniApp 开发中实现日历组件有所帮助。如果有任何问题或建议,欢迎在评论区交流讨论!

参考资料

  1. UniApp 官方文档:https://uniapp.dcloud.io/
  2. Vue.js 文档:https://cn.vuejs.org/
  3. 农历计算算法:https://github.com/jjonline/calendar.js
  4. 鸿蒙开发文档:https://developer.harmonyos.com/
相关推荐
lqj_本人22 分钟前
鸿蒙OS&UniApp实现视频播放与流畅加载:打造完美的移动端视频体验#三方框架 #Uniapp
uni-app·音视频·harmonyos
交叉编译之王 hahaha1 小时前
RK3568平台OpenHarmony系统移植可行性评估
华为·harmonyos
bysjlwdx5 小时前
uniapp婚纱预约小程序
小程序·uni-app
bestadc8 小时前
鸿蒙 ArkTS 常用的数组和字符串 操作方法
harmonyos
国服第二切图仔12 小时前
鸿蒙Next API17新特性学习之如何使用新增鼠标轴事件
harmonyos·鸿蒙系统
骑450的皮卡丘12 小时前
uniapp设置 overflow:auto;右边不显示滚动条的问题
css·uni-app·css3
lqj_本人12 小时前
鸿蒙OS&UniApp实现个性化的搜索框与搜索历史记录#三方框架 #Uniapp
华为·uni-app·harmonyos
lqj_本人12 小时前
鸿蒙OS&UniApp制作多选框与单选框组件#三方框架 #Uniapp
前端·javascript·uni-app
SuperHeroWu715 小时前
【HarmonyOS 5】鸿蒙mPaaS详解
华为·harmonyos·鸿蒙·mpaas·alipay