因为项目需要一个可滑动且可以选择时间区间的日历控件,网上看了下基本上都是点的左右滑动,于是乎自己实现了一个,请看大屏幕~
项目地址:https://github.com/UncleQing/SlidingCalendar
1.整体简介
2.日历部分
3.悬停年月栏
4.选择区间
5.总结
1.整体简介
基本架构如上
DateInfoBean,日历中最小单元,空白栏、年月标题、普通日期都是一个DateInfoBean
MonthInfoBean,日历中一个月即一个MonthInfoBean,若共有12个月则有12个MonthInfoBean,每个MonthInfoBean下有若干个DateInfoBean
AppDateTools,对日期、时间戳生成格式化时间字符串的工具类
UIUtils,主要是dp、sp、px互相转换的工具类
CalendarDateDecoration,自定义RecyclerView.ItemDecoration,主要用于悬停年月栏的实现
DateAdpater,自定义RecyclerView.Adapter
SlidingCalendarView,自定义日历控件,整合RecycleView配置、adapter设置以及日期的初始化
使用说明:
基本上使用只需将SlidingCalendarView导入需要显示的布局xml即可,不同业务需求可能需要自定义二次开发下
2.日历部分
首先是星期栏,没啥好说的,设置好宽度的LinearLayout,还可以根据需要设置是否显示,不过不是RecycleView部分,所以不会跟着滑动一直在头部
/**
* 添加星期
*/
private void addHeadView() {
setOrientation(LinearLayout.VERTICAL);
LinearLayout weekView = new LinearLayout(mContext);
LayoutParams headParams = new LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, UIUtils.dp2px(mContext, 32));
weekView.setLayoutParams(headParams);
weekView.setOrientation(LinearLayout.HORIZONTAL);
weekView.setBackgroundColor(Color.WHITE);
String[] arry = {"日", "一", "二", "三", "四", "五", "六"};
LayoutParams itemParams = new LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT);
itemParams.weight = 1;
for (String i : arry) {
TextView tv = new TextView(mContext);
tv.setLayoutParams(itemParams);
tv.setGravity(Gravity.CENTER);
tv.setTextColor(Color.BLACK);
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
tv.setText(i);
weekView.addView(tv);
}
addView(weekView);
}
然后日历,一个RecycleView,三种类型,一种悬停年月栏,一种空白占位,一种日期;根据已选择的日期数执行不同逻辑
/**
* 添加日期
*/
private void addCalendarView() {
mDateView = new RecyclerView(mContext);
mDateView.setBackgroundColor(Color.WHITE);
LayoutParams dateParams = new LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT);
mDateView.setLayoutParams(dateParams);
GridLayoutManager gridLayoutManager = new GridLayoutManager(mContext, 7);
mDateView.setLayoutManager(gridLayoutManager);
mDateView.addItemDecoration(new CalendarDateDecoration(mContext, new CalendarDateDecoration.ChooseCallback() {
@Override
public String getGroupId(int position) {
//返回年月栏数据,如2019年1月
int size = mList.size();
if (position < size) {
return mList.get(position).getGroupName();
} else {
return "";
}
}
}));
mAdapter = new DateAdpater(mContext, mList);
mAdapter.setListener(new DateAdpater.OnClickDayListener() {
@Override
public void onClickDay(View view, DateInfoBean bean, int position) {
//点击日期的listener
//获取已选择日期数目
int count = getSelectDayCount();
switch (count) {
case 0:
//尚未选择日期
bean.setChooseDay(true);
bean.setIntervalType(DateInfoBean.TYPE_INTERVAL_START);
//刷新当前View
mAdapter.notifyItemChanged(position);
break;
case 1:
//已选择一天
DateInfoBean firstBean = getFirstSelectDay();
if (isSameDay(firstBean, bean)) {
//同一天则取消选择
firstBean.setChooseDay(false);
mAdapter.notifyItemChanged(position);
} else {
//非同一天,为区间结束天
if (checkChooseDate(firstBean, bean) == 1) {
bean.setChooseDay(true);
refreshChooseUi(firstBean, bean);
}else if (checkChooseDate(firstBean, bean) == 0) {
bean.setChooseDay(true);
refreshChooseUi(bean, firstBean);
}
}
break;
default:
//已存在区间
clearAndSetStartDate(bean);
}
}
});
mDateView.setAdapter(mAdapter);
gridLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int i) {
//设置每行item个数,若是title则占7个位置,若是空白或日期则占一个位置
return mList.get(i).getType() == DateInfoBean.TYPE_DATE_TITLE ? 7 : 1;
}
});
addView(mDateView);
//滑动到当前月,即最后一项
mDateView.scrollToPosition(mList.size() - 1);
}
初始化日期
首先静态设置最大显示月份数MAX_MONTH_COUNT,考虑到可能存在的性能问题以及用户体验,先设置了14个月
MAX_MONTH_COUNT = 当前月 + 下个月 + 剩下若干月
然后使用Calendar相关API获取当前年月日
Calendar calendar = Calendar.getInstance();
获取当前年
calendar.get(Calendar.YEAR);
获取当前月,注意是从0开始的,所以使用的话应该+1
calendar.get(Calendar.MONTH);
设置日历月+1,若跨年则再获取年时也会+1
calendar.add(Calendar.MONTH, 1);
设置日历日+1,同理,若跨月再获取月也会+1
calendar.add(Calendar.DATE, 1);
/**
* 初始化日期
*/
private void initDate() {
List monthList = new ArrayList<>();
//设置月份
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MONTH, -MAX_MONTH_COUNT + 2);
for (int i = 0; i < MAX_MONTH_COUNT; i++) {
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH) + 1;
MonthInfoBean bean = new MonthInfoBean();
bean.setYear(year);
bean.setMonth(month);
monthList.add(bean);
calendar.add(Calendar.MONTH, 1);
}
//设置日期
calendar = Calendar.getInstance();
for (MonthInfoBean bean : monthList) {
List dateList = new ArrayList<>();
//设置当月第一天
calendar.set(bean.getYear(), bean.getMonth() - 1, 1);
int currentYear = calendar.get(Calendar.YEAR);
int currentMonth = calendar.get(Calendar.MONTH);
int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
//第一天之前空几天
int firstOffset = dayOfWeek - 1;
//当月最后一天
calendar.add(Calendar.MONTH, 1);
calendar.add(Calendar.DATE, -1);
int dayOfSum = calendar.get(Calendar.DATE);
//设置每月的dateList
//每月开始空白
for (int i = 0; i < firstOffset; i++) {
DateInfoBean dateBean = new DateInfoBean();
dateBean.setYear(currentYear);
dateBean.setMonth(currentMonth + 1);
dateBean.setDate(0);
dateBean.setType(DateInfoBean.TYPE_DATE_BLANK);
dateBean.setGroupName(dateBean.monthToString());
dateList.add(dateBean);
}
//每月日期
for (int i = 0; i < dayOfSum; i++) {
DateInfoBean dateBean = new DateInfoBean();
dateBean.setYear(currentYear);
dateBean.setMonth(currentMonth + 1);
dateBean.setDate(i + 1);
dateBean.setType(DateInfoBean.TYPE_DATE_NORMAL);
dateBean.setGroupName(dateBean.monthToString());
//设置今天明天后天
checkRecentDay(dateBean);
dateList.add(dateBean);
}
//每月结束空白
int lastDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
int lastOffset = 7 - lastDayOfWeek;
for (int i = 0; i < lastOffset; i++) {
DateInfoBean dateBean = new DateInfoBean();
dateBean.setYear(currentYear);
dateBean.setMonth(currentMonth + 1);
dateBean.setDate(0);
dateBean.setType(DateInfoBean.TYPE_DATE_BLANK);
dateBean.setGroupName(dateBean.monthToString());
dateList.add(dateBean);
}
bean.setDateList(dateList);
}
//填充日期
mList = new ArrayList<>();
for (MonthInfoBean bean : monthList) {
DateInfoBean titleBean = new DateInfoBean();
titleBean.setYear(bean.getYear());
titleBean.setMonth(bean.getMonth());
titleBean.setGroupName(titleBean.monthToString());
titleBean.setType(DateInfoBean.TYPE_DATE_TITLE);
mList.add(titleBean);
mList.addAll(bean.getDateList());
}
}
3.悬停年月栏
主要依靠自定义RecyclerView.ItemDecoration重写onDrawOver方法实现,不了解ItemDecoration的同学请移步了解学习下
参考:https://www.jianshu.com/p/b46a4ff7c10a
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//设置padding
outRect.left = 5;
outRect.right = 5;
outRect.bottom = mDividerHeight;
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(c, parent, state);
//画分割线
int childCount = parent.getChildCount();
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
for (int i = 0; i < childCount; i++) {
View view = parent.getChildAt(i);
float top = view.getBottom();
float bottom = view.getBottom() + mDividerHeight;
c.drawRect(left, top, right, bottom, mDividerPaint);
}
}
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
//悬停月份栏
GridLayoutManager manager = (GridLayoutManager) parent.getLayoutManager();
int position = manager.findFirstVisibleItemPosition();
if (position == RecyclerView.NO_POSITION) {
return;
}
RecyclerView.ViewHolder viewHolder = parent.findViewHolderForAdapterPosition(position);
View child = null;
if (viewHolder != null) {
child = viewHolder.itemView;
}
boolean flag = false;
if (isLastInGroup(position) && null != child) {
if (child.getHeight() + child.getTop() < mTop) {
c.save();
flag = true;
c.translate(0f, (child.getHeight() + child.getTop() - mTop));
}
}
RectF rect = new RectF(
parent.getPaddingLeft(),
parent.getPaddingTop(),
(parent.getRight() - parent.getPaddingRight()),
(parent.getPaddingTop() + mTop));
c.drawRect(rect, mPaint);
c.drawText(mCallback.getGroupId(position),
rect.centerX(),
rect.centerY() + mTopPadding,
mTextPaint);
if (flag) {
c.restore();
}
}
4.选择区间
这个demo做的是开始区间天随便点,结束区间天可以在开始天之前或之后,但是不能超过最大区间数,用一个常量MAX_RANGE控制。使用方法checkChooseDate(DateInfoBean firstBean, DateInfoBean bean)控制,可以根据返回值判断选择的结束天是否符合规定,并得知哪一天是考前的一天
/**
* 判断bean和firstBean日期前后
* -1:无效或超出最大范围或同一天
* 0: bean在firstBean之前
* 1:bean在firstBean之后
*
* @param firstBean
* @param bean
* @return
*/
private int checkChooseDate(DateInfoBean firstBean, DateInfoBean bean) {
if (null == firstBean || null == bean || isSameDay(firstBean, bean)) {
return -1;
}
long firstLongTime = AppDateTools.getStringToDate(firstBean.dateToString());
long selectLongTime = AppDateTools.getStringToDate(bean.dateToString());
long diffLongTime = selectLongTime - firstLongTime;
if (AppDateTools.diffTime2diffDay(Math.abs(diffLongTime)) > MAX_RANGE) {
return -1;
}
return selectLongTime - firstLongTime > 0 ? 1 : 0;
}
若得结果为1则确认firstBean是开始天,bean是结束天,若得0则将两天颠倒传入refreshChooseUi即可
//非同一天,为区间结束天
if (checkChooseDate(firstBean, bean) == 1) {
//第一次选择之后的一天
bean.setChooseDay(true);
refreshChooseUi(firstBean, bean);
} else if (checkChooseDate(firstBean, bean) == 0) {
//第一次选择之前的一天
bean.setChooseDay(true);
refreshChooseUi(bean, firstBean);
}
PS:实际上根据不同业务需求可能第一天必须是今天之前或之后的,这块需要根据实际情况再追加判断逻辑了
然后再介绍下初始进入的默认区间选择
当前进入默认选择今天为区间开始天,在checkRecentDay中设置的
if (bean.getYear() == currentYear && bean.getMonth() == currentMonth && bean.getDate() == currentDate) {
//今天
bean.setRecentDay(true);
bean.setRecentDayName(DateInfoBean.STR_RECENT_TODAY);
//默认选择今天
bean.setChooseDay(true);
bean.setIntervalType(DateInfoBean.TYPE_INTERVAL_START);
return;
}
如果需要设置其他默认值,可使用public void initDate(DateInfoBean fristBean),传入一个bean,选中该bean到今天的区间
/**
* 初始化默认选择区间:fristBean - mTodayBean
* @param fristBean
*/
public void initDate(DateInfoBean fristBean){
if (fristBean == null || mTodayBean == null) {
return;
}
clearAndSetStartDate(fristBean);
mTodayBean.setChooseDay(true);
refreshChooseUi(fristBean, mTodayBean);
}
如果需要不能选择今天以后的日期,则使用checkIsAfterToday(DateInfoBean bean)
/**
* 判断是否今天之后
* @param bean
* @return
*/
private boolean checkIsAfterToday(DateInfoBean bean) {
if ( null == bean || null == mTodayBean) {
return true;
}
//转为时间戳,时间戳差转化天数判断最大范围
long todayLongTime = AppDateTools.getStringToDate(mTodayBean.dateToString(), AppDateTools.DATE_FORMAT2);
long selectLongTime = AppDateTools.getStringToDate(bean.dateToString(), AppDateTools.DATE_FORMAT2);
return selectLongTime - todayLongTime > 0;
}
并在adapter的listener使用
mAdapter.setListener(new DateAdpater.OnClickDayListener() {
@Override
public void onClickDay(View view, DateInfoBean bean, int position) {
if (isInAnim) {
return;
}
int count = getSelectDayCount();
switch (count) {
case 0:
//尚未选择日期
if (!checkIsAfterToday(bean)){
//选择是今天或之前
bean.setChooseDay(true);
bean.setIntervalType(DateInfoBean.TYPE_INTERVAL_START);
mAdapter.notifyItemChanged(position);
}
break;
case 1:
//已选择一天
DateInfoBean firstBean = getFirstSelectDay();
if (isSameDay(firstBean, bean)) {
//同一天则取消选择
firstBean.setChooseDay(false);
mAdapter.notifyItemChanged(position);
} else {
//非同一天,为区间结束天或开始天
if (checkChooseDate(firstBean, bean) == 0) {
//bean在first之前
bean.setChooseDay(true);
refreshChooseUi(bean, firstBean);
}else if (checkChooseDate(firstBean, bean) == 1){
//bean在first之后
if (!checkIsAfterToday(bean)){
//不超过今天
bean.setChooseDay(true);
refreshChooseUi(firstBean, bean);
}
}
}
break;
default:
//已存在区间
clearAndSetStartDate(bean);
}
}
});
5.总结
总的来说写的时候碰到的坑不少,但是实际都解决之后发现好些没有什么需要值得提醒大家注意的,所以如果有问题的话请大家多和我交流吧。另外项目中定位目标日期bean基本都是循环遍历整个list,当前设置14月的list长度已达到四五百个item之多,所以觉得频繁调用肯定会造成性能问题,但暂时没有好的代替办法,如果大家有什么建议想法欢迎来GitHub提issue
再贴一下项目地址:https://github.com/UncleQing/SlidingCalendar