先看几张动态的效果图吧!
项目地址:https://github.com/SheHuan/CalendarView
这里主要记录一下在编写日历控件过程中一些主要的点:
一、主要功能
- 1、支持农历、节气、常用节假日
- 2、日期范围设置,默认支持的最大日期范围[1900.1~2049.12]
- 3、禁用日期范围设置
- 4、初始化选中单个或多个日期
- 5、单选、多选操作
- 6、跳转到指定日期
- 7、替换农历为指定文字
- 8、通过自定义属性定制日期外观,以及简单的日期item布局配置
- 9、......
二、基本结构
我们要实现的日历控件采用ViewPager作为主框架,CalendarView继承ViewPager,这样就天生拥有左右滑动和缓存的功能。目前我们设定日历左右滑动为月份切换的操作,每一个月份显示通过自定义ViewGroup实现,也就是我们的MonthView,月份中的日期是通过layout布局解析出的View,根据月份的不同每个MonthView可能包含6 x 7或5 x 7个日期View,由于给ViewPager绑定数据需要通过PagerAdapter,所以继承PagerAdapter我们扩展了一个CalendarPagerAdapter,来完成MonthView的相关初始化和日期数据的绑定。
三、计算每个MonthView需要填充的日期数据
从上边的截图可以看出,每个MonthView的日期数据应该由上个月的后0~6天、当前月的天数和下个月的前0~6天组成。首先计算出当前月有多少天,这个简单,以及根据年月算出当前月的第一天是星期几:
public static int getFirstWeekOfMonth(int year, int month) {
Calendar calendar = Calendar.getInstance();
calendar.set(year, month, 1);
return calendar.get(Calendar.DAY_OF_WEEK) - 1;
}
返回0代表周日,1~6代表周一到周六,以上边的截图为例,可以知道2017年5月的第一天是周一:week = getFirstWeekOfMonth(2017, 5-1)
,按照如下伪码则可计算出包含的上个月的日期:
for (int i = 0; i < week; i++) {
ld = 上个月天数 - week + 1 + i;
}
至于包含的下个月的日期和当前MonthView显示的行数有关,如果 当前月的天数+week
可以被7整除则不需要包含下月日期,否则需要计算包含的下月日期,伪码如下:
for (int i = 0; i < 7 * 显示的行数 - 当月天数 - week; i++) {
nd = i + 1;
}
这样需要的日期数据就计算完了,详细的算法可参考源码。
四、 计算日历的总页数
总页数应由日历的起始年月得到,其实就是确定ViewPager的总页数,这样好理解点。可按照如下方法计算:
count = (dateEnd[0] - dateStart[0]) * 12 + dateEnd[1] - dateStart[1] + 1
其中dateStart、dateEnd是包含日历开始年月和结束年月的数组。这个count也是CalendarPagerAdapter必须的。
五、用position计算日期
PagerAdapter有个instantiateItem()方法:
public Object instantiateItem(ViewGroup container, int position) {
return instantiateItem((View) container, position);
}
来创建ViewPager的每一页,所以日历每一页也是在这里创建的,也就是MonthView,这里有个关键的点就是根据 positon 参数推算出日历每一页对应的年月,然后通过年月计算出当前MonthView需要的日期数据。如何根据position推算出年月呢?
public static int[] positionToDate(int position, int startY, int startM) {
int year = position / 12 + startY;
int month = position % 12 + startM;
if (month > 12) {
month = month % 12;
year = year + 1;
}
return new int[]{year, month};
}
其中startY、startM代表日历的其实年月。有了对应的年月就可以用第二点中的方式计算日期数据,然后填充到MothView中。
六、MothView
前边已经提到了,MonthView继承ViewGroup,也就是日历的每一页,接收到日期数据后,在MonthView中根据数据构造对应的日期View,然后添加View到MonthView中,最后通过onMeasure、onLayout确定每个View最终大小和位置。到这里运行一个ViewPager的基本条件就满足了,在上边提到的instantiateItem()方法中完成MothView的初始化:
public Object instantiateItem(ViewGroup container, int position) {
MonthView view = new MonthView(container.getContext());
//根据position计算对应年、月
int[] date = CalendarUtil.positionToDate(position, dateStart[0], dateStart[1]);
view.setDateList(CalendarUtil.getMonthDate(date[0], date[1]), SolarUtil.getMonthDays(date[0], date[1]));
container.addView(view);
return view;
}
这里只保留了核心的代码,当日历切换月份时,会自动根据position计算出对应月份的日期数据,然后传给MonthView,最后将MonthView添加到ViewPager中。
七、切换月份选中日期
按照目前的设定,当选择当前月的某天后,然后切换月份,新的月份中会找到上次选中的日期,并标记为选中状态,如果找不到则选中新月份的最后一天。其实逻辑很简单,关键是如何在新月份中找到相应的日期并选中。首先记录上次选中的日期,由于ViewPager默认会缓存两页,再加上当前页共三页,在CalendarPagerAdapter中根据position保存三页缓存,当ViewPager切换到某一页后会执行如下回调:
addOnPageChangeListener(new SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
}
});
在onPageSelected(int position)方法中通过position从缓存中拿到对应的MonthView,也是是切换到的页,这样就能在MonthView中根据记录的日期找到对应的子日期View,然后更改为选中状态。
八、多选
一个理想的多选功能应该是在当前月份选中多个日期后,切换到其它月份,之后回到有选中日期的月份依然能够标记出选中的日期,因为ViewPager有默认的三页缓存,所以在当前月份切换到上月或下月不会有什么问题,但如果切换到前几个月或后几个月,再回到有选中日期的月份,由于之前缓存的页面已经被销毁重建,所以选中的月份也就看不到了。我们的日期点击事件在MonthView中,当每次点击选中时我们需要记录对应年月选中的日期,取消选中时要从记录中删除对应日期,怎么保存呢?在CalendarView类中我们定义一个SparseArray
SparseArray<HashSet<Integer>> chooseDate = new SparseArray<>()
其中的HashSet就是指定年月选中的日期,按照我们的规则设定不同年月转换得到的position是唯一对应的,所以我们用position作为SparseArray的key,最后在CalendarView中接收选中或取消选中的操作:
public void setChooseDate(int day, boolean flag, int position) {
if (position == -1) {
position = currentPosition;
}
HashSet<Integer> days = chooseDate.get(position);
if (flag) {
if (days == null) {
days = new HashSet<>();
chooseDate.put(position, days);
}
days.add(day);
positions.add(position);
} else {
days.remove(day);
}
}
之后就是在月份切换过程中,根据保存的日期数据刷新对应的MonthView,实现选中状态的恢复,这个和第六点类似。
九、跳转到指定日期
要跳转到指定日期,首先要根据日期的年月计算出目标MonthView在日历中的position:
public static int dateToPosition(int year, int month, int startY, int startM) {
return (year - startY) * 12 + month - startM;
}
ViewPager有一个setCurrentItem(int item, boolean smoothScroll)
方法,这样就能跳转到position对应的MonthView,然后结合第六点的方法选中对应的日期View。这样跳转到日历设定日期范围内的任意一天都是没问题的。
十、自定义日历样式
CalendarView提供的自定义属性如下:
属性名 | 格式 | 描述 | 默认值 |
---|---|---|---|
choose_type | enum | 设置单选(single)、多选(multi) | single |
show_lunar | boolean | 是否显示农历 | true |
show_last_next | boolean | 是否在MonthView显示上月和下月日期 | true |
show_holiday | boolean | 是否显示节假日 | true |
show_term | boolean | 是否显示节气 | true |
switch_choose | boolean | 单选时切换月份,是否选中上次的日期 | true |
solar_color | color | 阳历日期的颜色 | |
solar_size | integer | 阳历的日期尺寸 | 14 |
lunar_color | color | 农历的日期颜色 | |
lunar_size | integer | 农历的日期尺寸 | 8 |
holiday_color | color | 节假日、节气的颜色 | |
choose_color | color | 选中的日期颜色 | |
day_bg | reference | 选中的日期背景(图片) |
CalendarView相关方法:
方法名 | 描述 |
---|---|
setInitDate(String date) | 设置日历的初始显示年月 |
setStartEndDate(String startDate, String endDate) | 设置日历开始、结束年月 |
setDisableStartEndDate(String startDate, String endDate) | 设置日历的禁用日期范围(小于startDate、大于endDate禁用) |
setSpecifyMap(HashMap<String, String> map) | 将显示农历的区域替换成指定文字 |
setSingleDate(String date) | 设置单选时初始选中的日期(不设置则不默认选中) |
getSingleDate() | 得到单选时选中的日期 |
setMultiDate(List<String> dates) | 设置多选时默认选中的日期集合 |
getMultiDate() | 得到多选时选中的全部日期 |
toSpecifyDate(int year, int month, int day) | 单选时跳转到指定年月日 |
setOnCalendarViewAdapter(int layoutId, CalendarViewAdapter adapter) | 设置自定义日期item样式 |
init() | 日期初始化(以上属性配置完后调用) |
setOnPagerChangeListener(OnPagerChangeListener listener) | 设置月份切换回调 |
setOnSingleChooseListener(OnSingleChooseListener listener) | 设置单选回调 |
setOnMultiChooseListener(OnMultiChooseListener listener) | 设置多选回调 |
today() | 单选时跳转到今天 |
nextMonth() | 跳转到下个月 |
lastMonth() | 跳转到上个月 |
nextYear() | 跳转到下一年的当前月 |
lastYear() | 跳转到上一年的当前月 |
toStart() | 跳转到日历的开始年月 |
toEnd() | 跳转到日历的结束年月 |
CalendarUtil.getCurrentDate() | 获得当前日期(今天) |
默认的日期布局是阳历、阴历垂直排列,节假日会覆盖在农历上显示,这个从上边的静态截图可以看出。如果要使用其它的排列方式,例如水平排列等,就需要提供一个自定的layout(但目前只支持两个TextView显示)。例如:
calendarView.setOnCalendarViewAdapter(R.layout.item_layout, new CalendarViewAdapter() {
@Override
public TextView[] convertView(View view, DateBean date) {
TextView solarDay = (TextView) view.findViewById(R.id.solar_day);
TextView lunarDay = (TextView) view.findViewById(R.id.lunar_day);
return new TextView[]{solarDay, lunarDay};
}
});
给CalendarView绑定一个接口,传入lauoyt,然后返回一个代表阳历和农历的TextView数组。
十一、WeekView
我们将日期和星期的显示功能分割开了,所以CalendarView并不负责星期的显示,
WeekView是星期显示的自定义View,从周日开始依次是周一到周六,可通过自定义属性来配置星期的显示文字,以及文字的颜色、尺寸,这个还是相对简单,具体可见Github中的使用介绍。
十二、小结
这里我们只介绍了日历的基本实现原理,和一些关键的点,其实这种实现方式相对还是比较简单的,容易理解,当然难免有不足的地方,后边根据需要再逐步完善和扩展吧。尽管Github上有许多现成的Calendar,但自己动手实现一个还是收获满满,一个看起来简单的东西,只有亲自尝试了才能体会到其中的滋味,最后希望对大家有所帮助吧!