仿飞猪的可滑动日历

前言

因为项目需要一个可滑动且可以选择时间区间的日历控件,网上看了下基本上都是点的左右滑动,于是乎自己实现了一个,请看大屏幕~

项目地址: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

你可能感兴趣的:(需求实现)