实现一个仿小米日历控件

项目地址:https://github.com/landscapeside/DragCalendar

先看效果图:

效果图

根据效果,我们可以看到,要实现该控件,需要具备:

  1. 容器以及触摸事件处理
  2. 周日历布局以及选择,切换上下周处理
  3. 月日历布局以及选择,切换上下月处理

首先说说容器

对于其他使用者来说,整个日历都应该是一个类似于RelativeLayout之类的容器,然后里面包含有我们需要的日历控件,而且因为在滑动日历的时候也会移动下面的listview或者scrollView部分,所以其实这是一个嵌套滑动控件,它必须能合理的处理不同的手势场景(比如在listView的内容滑动到顶部时再下滑会滑出日历,以及上滑收起日历等),所以它在设计上应该类似于drawerLayout等手势类容器,并且需要在滑动时通过滑动进度来动态设置动画。

因此,容器控件需要做到:

  1. 触摸事件的拦截与处理
  2. 周日历和月日历的收起和展开动画

凡是触摸事件处理与拦截都是通过复写onInterceptTouchEventonTouchEvent来实现,一般有如下方式:

  1. 逻辑全部写在这两个方法里,需要开发者自己去记录位置坐标,计算方向等等,并且需要记录若干状态
  2. 用安卓提供的GestureDetector类简化某些常用手势场景,当然依然需要复写onInterceptTouchEventonTouchEvent,相对来说代码简化一些
  3. 用安卓提供的ViewDragHelper类(以下简称VDH)来处理,该类更简化,相对GestureDetector来说其增加了容器内孩子视图的判断,正是因为这点,可以让开发者方便的得知触摸点由哪个孩子视图处理,很大程度上避免处理坐标系信息

这里我们采用的方案主要是VDH,方案1和方案2为辅助,具体实现方式请参考用viewDragHelper来写刷新控件一用viewDragHelper来写刷新控件二用viewDragHelper来写刷新控件三

接下来是代码的架构方面

更深入的分析需求,我们会发现,容器类其实应该负责处理内容区与周日历/月日历的联动,而至于日历内部渲染,逻辑处理应该交由其他类来处理,这里我们再次细分:

  • 逻辑部分(Presenter层):向日历视图发出刷新,定位命令,给调用者暴露关闭,返回今天方法,并且给调用者提供日期选中,滚动到某月/某周的消息回调
  • 视图部分(View层):从效果图来看,周日历/月日历采用viewPager来实现即可,其重点负责日历的渲染功能,同时当Presenter层被调用返回今天以及更新了数据源需要刷新时,最终需要视图层来刷新页面以及滑动到正确的周/月

逻辑部分Presenter

根据使用对象和场景,Presenter提供的能力分为三类:

  • 供控件使用者调用的,属于开放API部分
  • 供视图层调用,一般是用户对视图操作了之后由视图通知Presenter做某事
  • 消息回传,视图通知了Presenter之后,由Presenter来通知使用者来更新UI或者做其他事务
开放API
  • 返回今天(供控件使用者调用)
  • 关闭月日历(供控件使用者调用)
  • 设置数据源
示例图

开放API由使用者调用,因为根据效果图来看,日历的标题栏实为固定在界面顶部,正常情况下被toolBar所遮挡,滑动时逐步显现,因此将标题栏单独实现为一个控件,所以需要日历控件和标题栏控件互动:

  1. 标题栏上有两个按钮,返回今天和收起,点击后应该通知日历控件做相应操作
  2. 日历控件滑动或者选择后,可能会导致标题栏上文字显示改变,因此需要日历控件提供回调

从代码实现角度,由于这些操作实际为逻辑控制部分,因此应该交由presenter来实现,调用者应该通过presenter作为桥梁来操作控件视图,且控件视图回调的消息通过presenter回传给调用者

根据需求,在月日历下,会根据某接口返回的参数来标识当天是否有数据

month_card.png

此数据来源于网络请求,是异步操作,因此只能有调用者在网络请求返回之后将数据传入控件且刷新,与
返回今天和关闭相同,调用者最好不要直接操作控件视图,而通过presenter作为桥接,间接通知视图刷新页面,
使得调用者与视图解耦,将日历视图具体实现逻辑隐蔽起来。几个主要代码实现如下:

    // 设置数据源
    public <T> void parseData(List<T> sources) {
        if (calendarDotVO == null) {
            throw new IllegalArgumentException("Dot Data must not be null");
        }
        calendarDotVO.parseData(sources);
        viewBuilder().dragCalendarLayout.reDraw();
    }

    // 返回今天
    public void backToday() {
        setSelectTime(todayTime);
        viewBuilder().dragCalendarLayout.backToday();
    }

    // 关闭月日历
    public void close() {
        viewBuilder().dragCalendarLayout.setExpand(false);
    }

当点击返回今天时,需要做到:

  1. 回滚周/月视图至今天所在的周/月,后文就讨论具体实现
  2. 通知调用者重新选中今天

供日历视图调用的presenter接口,一般为通知调用者进行业务处理

根据设计,日历视图有如下几个会引发调用者业务处理的操作:

  1. 周日历下,左右滑动切换会导致日期的自动切换,比如选中日期为周二且滑至上一周时,同时日期切换至该周周二
  2. 月日历下,左右滑动切换日历标题栏上展示日期
  3. 月日历下标题栏展示日期或者选择日期非今日,展示返回今日按钮

之前说过,周日历/月日历实际为viewPager实现,因此要实现滑动切换逻辑只需监听ViewPager.OnPageChangeListener,因月日历和周日历的实际实现不同,这里用枚举CalendarPagerChangeEnum来区分:

    MONTH{
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {

        }

        @Override
        public void onPageSelected(int position) {
            CalendarPresenter.instance()
                    .setCurrentScrollDate(DateUtils.getTagTimeStr(
                            CalendarType.MONTH.calculateByOffset(position)));
        }

        @Override
        public void onPageScrollStateChanged(int state) {
            if (stateChangeListener != null) {
                stateChangeListener.onStateChange(state);
            }
            if (state == ViewPager.SCROLL_STATE_DRAGGING) {
                ((MonthCalendarAdapter)adapter).showDivider(true);
            } else if (state == ViewPager.SCROLL_STATE_IDLE) {
                ((MonthCalendarAdapter)adapter).showDivider(false);
            }
        }
    },
    WEEK{
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {

        }

        @Override
        public void onPageSelected(int position) {
            Calendar calendar = CalendarPresenter.instance().selectCalendar();
            int week = CalendarType.WEEK.defPosition() - position;
            if (week != CalendarPresenter.instance().getWeekDiff()) {
                calendar.add(Calendar.DATE, -(week - CalendarPresenter.instance().getWeekDiff()) * 7);
                CalendarPresenter.instance().setSelectTime(DateUtils.getTagTimeStr(calendar),true);
            }
        }

        @Override
        public void onPageScrollStateChanged(int state) {

        }
    };

这里有一个小tips,为了提升用户体验,月日历滑动时需展示边界线,因此才有

    @Override
    public void onPageScrollStateChanged(int state) {
        if (state == ViewPager.SCROLL_STATE_DRAGGING) {
            ((MonthCalendarAdapter)adapter).showDivider(true);
        } else if (state == ViewPager.SCROLL_STATE_IDLE) {
            ((MonthCalendarAdapter)adapter).showDivider(false);
        }
    }

同时Presenter提供选择日期和设置月日历滚动日期接口:

    // 设置选中日期并触发消息回传,通知调用者进行业务处理
    public void setSelectTime(String selectTime, boolean autoReset) {
        if (TextUtils.isEmpty(selectTime)) {
            throw new IllegalArgumentException("selectTime can not be empty");
        }
        if (DateUtils.diff(todayTime, selectTime) < 0) {
            if (autoReset) {
                selectTime = todayTime;
            } else {
                return;
            }
        }
        boolean close  = false;
        this.selectTime = selectTime;
        if (callbk != null) {
            close = callbk.onSelect(selectTime);
        }
        notifyCalendarBar(selectTime);
        viewBuilder().dragCalendarLayout.focusCalendar();
        if (close) {
            close();
        }
    }

    // 月日历下当前滚动到某月时的日期设置
    public void setCurrentScrollDate(String currentScrollDate) {
        if (TextUtils.isEmpty(currentScrollDate)) {
            throw new IllegalArgumentException("currentScrollDate can not be empty");
        }
        if (!currentDate.equals(currentScrollDate)) {
            currentDate = currentScrollDate;
            currentDateCallbk();
            notifyCalendarBar(currentScrollDate);
        }
    }

    // 当前滚动日期的消息回传
    private void currentDateCallbk() {
        if (callbk != null) {
            callbk.onScroll(currentDate);
        }
    }

    // 日历标题栏的消息回传
    private void notifyCalendarBar(String barDate) {
        if (callbk != null) {
            boolean isToday;
            if (DateUtils.diffMonth(todayTime, barDate) == 0) {
                isToday = TextUtils.equals(todayTime, selectTime);
            } else {
                isToday = false;
            }
            callbk.onCalendarBarChange(barDate,isToday);
        }
    }
消息回传通知

根据之前的约定,调用者只与presenter交互,同样的,presenter接受到日历视图的操作后,由presenter通知调用者进行业务处理

    // presenter提供的消息通知接口
    public interface ICallbk {
        void onCalendarBarChange(String currentTime, boolean isToday);
        void onScroll(String currentTime);
        boolean onSelect(String selectTime);
    }

    ICallbk callbk = null;

    public void setCallbk(ICallbk callbk) {
        this.callbk = callbk;
        currentDateCallbk();
        notifyCalendarBar(currentDate);
    }

此处在设置消息通知接口时需强制触发消息一次,目的是为了在初始阶段刷新日历标题栏

视图部分(VIEW)

视图层主要负责:

  1. 周视图渲染以及用户操作后对presenter发起消息通知
  2. 月视图渲染以及用户操作后对presenter发起消息通知

从结构上来说,两者都是采用viewPager实现,不同点即其渲染方式不同,因此这里也可采用枚举CalendarType加以区分:

    public enum CalendarType implements IAdapterRefresh,IAdapterConstant {
        MONTH {
            @Override
            public void refresh(ViewGroup view, int position) {
                //给view 填充内容
    
                //设置开始时间为本周日
                Calendar day = calculateByOffset(position);
                view.setTag(day.get(Calendar.MONTH) + "");
                //找到这个月的第一天所在星期的周日
                day.add(Calendar.DAY_OF_MONTH, -(day.get(Calendar.DAY_OF_MONTH) - 1));
                int day_of_week = day.get(Calendar.DAY_OF_WEEK) - 1;
                day.add(Calendar.DATE, -day_of_week);
                ((ICalendarCard)view).render(day);
            }
    
            @Override
            public int getCount() {
                return 1200;
            }
    
            @Override
            public int defPosition() {
                return getCount() - 1;
            }
        },
    
        WEEK {
            @Override
            public void refresh(ViewGroup view, int position) {
                //给view 填充内容
    
                //设置开始时间为本周日
                Calendar day = calculateByOffset(position);
                int day_of_week = day.get(Calendar.DAY_OF_WEEK) - 1;
                day.add(Calendar.DATE, -day_of_week);
                ((ICalendarCard)view).render(day);
            }
    
            @Override
            public int getCount() {
                return 4800;
            }
    
            @Override
            public int defPosition() {
                return getCount() - 1;
            }
        }
    }

    public interface IAdapterRefresh {
        void refresh(ViewGroup view, int position);
    }

    public interface IAdapterConstant {
        int getCount();
        int defPosition();
    }

枚举CalendarType中只需处理逻辑部分,这里为计算出每周/月上起始时间(这里的起始时间并非每一周/月的第一天,而应该是每一张周卡片/月卡片第一行第一列开始的那个日期,因日历横向是从周日开始,所以只需算出第一行的周日即可),并调用相应的周/月视图进行渲染。而周/月视图来源于不同的PagerAdapter(因为周/月为两个不想干的viewpager),以下以周日历适配器为例:

    public class WeekCalendarAdapter extends CalendarBaseAdapter {
        private List<View> views = new ArrayList<>();
        WeekCard currentCard;
    
        public WeekCalendarAdapter(Context context) {
            views.clear();
            for (int i = 0; i < 4; i++) {
                views.add(new WeekCard(context));
            }
        }
    
        @Override
        public int getCount() {
            return CalendarType.WEEK.getCount();
        }
    
        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }
    
        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
        }
    
        public WeekCard currentCard() {
            return currentCard;
        }
    
        @Override
        public void setPrimaryItem(ViewGroup container, int position, Object object) {
            currentCard = (WeekCard) object;
            super.setPrimaryItem(container, position, object);
        }
    
        @Override
        public Object instantiateItem(ViewGroup container, final int position) {
            ViewGroup view = (ViewGroup) views.get(position % views.size());
            int index = container.indexOfChild(view);
            if (index != -1) {
                container.removeView(view);
            }
            try {
                container.addView(view);
            } catch (Exception e) {
    
            }
            CalendarType.WEEK.refresh(view, position);
            return view;
        }
    }

其中,适配器用4个视图循环使用达到节省资源的目的,WeekCard实现了ICalendarCard接口:

    public interface ICalendarCard {
        void render(final Calendar today);
    }

然后是周日历viewPager:

    public class WeekView extends LinearLayout implements ICalendarView {

        ViewPager weekPager;
        WeekCalendarAdapter adapter;
    
        public WeekView(Context context) {
            super(context);
            setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
            setOrientation(VERTICAL);
    
            View.inflate(getContext(), R.layout.calendar_pager, this);
            weekPager = (ViewPager) findViewById(R.id.cal_pager);
            ViewGroup.LayoutParams layoutParams = weekPager.getLayoutParams();
            layoutParams.height = dp2px(getContext(), WEEK_HEIGHT);
            weekPager.setLayoutParams(layoutParams);
    
            adapter = new WeekCalendarAdapter(context);
            weekPager.setAdapter(adapter);
            weekPager.setCurrentItem(CalendarType.WEEK.defPosition());
            weekPager.setOnPageChangeListener(CalendarPagerChangeEnum.WEEK.setAdapter(adapter));
        }
    
        @Override
        public void backToday() {
            weekPager.setCurrentItem(CalendarType.WEEK.defPosition(), true);
        }
    
        @Override
        public int currentIdx() {
            return weekPager.getCurrentItem();
        }
    
        @Override
        public void focusCalendar() {
            weekPager.setCurrentItem(CalendarType.WEEK.defPosition() - CalendarPresenter.instance().getWeekDiff(), true);
            reDraw();
        }
    
        @Override
        public void reDraw() {
            adapter.notifyDataSetChanged();
        }
    }

我们会发现,因为ViewPager以及包含它的容器为动态实例化,因此需要手动的设置高度而无法用系统的wrap_content属性,因此这里需要开发者自我计算一个合理的高度WEEK_HEIGHT,此处作者设置每周高45dp,一个月最高为305dp(6行的周加上上下边距总共305dp,详细设置见Range类)

    public class Range {
        public static final int MONTH_HEIGHT = 305;
        public static final int WEEK_HEIGHT = 45;
        public static final int DAY_HEIGHT = 45;
        public static final int MONTH_PADDING_TOP = 25;
        public static final int MONTH_PADDING_BOTTOM = 10;
    }

月日历实现与之类似,就不赘述
另外,在前述的presenter实现中,提到返回今日时需要通时回滚周/月日历视图到当前周/月,其实际为相应的ViewPager重设当前页,因此在前述的presenter的backToday实现中调用的viewBuilder().dragCalendarLayout.backToday();实际上是调用周视图WeekView的weekPager.setCurrentItem(CalendarType.WEEK.defPosition(), true);以及月视图MonthView的monthPager.setCurrentItem(CalendarType.MONTH.defPosition(), true);

周,月视图渲染实现

周卡片的渲染,实际上只需要7个横向排列的日期,而月卡片实际上是纵向排6个周卡片,这里给出主要的渲染代码:

    // 周卡片
    @Override
    public void render(Calendar today) {
        for (int a = 0; a < 7; a++) {
            final int dayOfMonth = today.get(Calendar.DAY_OF_MONTH);
            final ViewGroup dayOfWeek = (ViewGroup) getChildAt(a);
            dayOfWeek.setTag(DateUtils.getTagTimeStr(today));
            dayOfWeek.setOnClickListener(v -> CalendarPresenter.instance().setSelectTime(dayOfWeek.getTag().toString()));

            //如果是选中天的话显示为蓝色
            if (CalendarPresenter.instance().getSelectTime().equals(DateUtils.getTagTimeStr(today))) {
                ((TextView) dayOfWeek.findViewById(R.id.gongli)).setText(DateUtils.getTagTimeStrByMouthandDay(today));
                renderSelect(dayOfWeek, DateUtils.getTagTimeStr(today));
            } else {
                ((TextView) dayOfWeek.findViewById(R.id.gongli)).setText(dayOfMonth + "");
                if (DateUtils.diff(CalendarPresenter.instance().today(), DateUtils.getTagTimeStr(today)) >= 0) {
                    renderNormal(dayOfWeek, DateUtils.getTagTimeStr(today));
                } else {
                    renderGray(dayOfWeek, DateUtils.getTagTimeStr(today));
                }
            }
            today.add(Calendar.DATE, 1);
        }
    }

    // 月卡片
    @Override
    public void render(Calendar today) {
        int pageMonth = (Integer.parseInt((String) getTag()));
        //一页显示一个月+7天,为42;
        for (int b = 0; b < 6; b++) {
            final ViewGroup view = (ViewGroup) monthContent.getChildAt(b);
            int currentMonth = today.get(Calendar.MONTH);
            if (pageMonth != currentMonth && b != 0) {
                view.setVisibility(INVISIBLE);
                today.add(Calendar.DATE, 7);
            } else {
                view.setVisibility(VISIBLE);
                for (int a = 0; a < 7; a++) {
                    final int dayOfMonth = today.get(Calendar.DAY_OF_MONTH);
                    final ViewGroup dayOfWeek = (ViewGroup) view.getChildAt(a);
                    ((TextView) dayOfWeek.findViewById(R.id.gongli)).setText(dayOfMonth + "");
                    dayOfWeek.setTag(DateUtils.getTagTimeStr(today));

                    dayOfWeek.setOnClickListener(v -> CalendarPresenter.instance().setSelectTime(dayOfWeek.getTag().toString()));

                    //不是当前月浅色显示
                    currentMonth = today.get(Calendar.MONTH);
                    if (pageMonth != currentMonth) {
                        renderInvisible(dayOfWeek);
//                        renderGray(dayOfWeek,DateUtils.getTagTimeStr(today));
                        today.add(Calendar.DATE, 1);
                    } else {
                        //如果是选中天的话显示为蓝色
                        if (CalendarPresenter.instance().getSelectTime().equals(DateUtils.getTagTimeStr(today))) {
                            selectPos = calculatePos(b);
                            renderSelect(dayOfWeek, DateUtils.getTagTimeStr(today));
                        } else {
                            if (DateUtils.diff(CalendarPresenter.instance().today(), DateUtils.getTagTimeStr(today)) >= 0) {
                                renderNormal(dayOfWeek, DateUtils.getTagTimeStr(today));
                            } else {
                                renderGray(dayOfWeek, DateUtils.getTagTimeStr(today));
                            }
                        }
                        today.add(Calendar.DATE, 1);
                    }
                }
            }
        }
    }

关于仿小米日历的实现到此结束,祝各位天天开心,生活愉快!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,810评论 25 707
  • 内容抽屉菜单ListViewWebViewSwitchButton按钮点赞按钮进度条TabLayout图标下拉刷新...
    皇小弟阅读 46,732评论 22 665
  • Android UI相关开源项目库汇总OpenDigg 抽屉菜单MaterialDrawer ★7337 - 安卓...
    黄海佳阅读 8,700评论 3 77
  • 平平淡淡的幸福,总是让人留恋。 我什么时候也可以有呢。真想就此时光不再流淌,我不再老去,不用去面对许许多多的人间杂...
    蜜呢阅读 250评论 0 0
  • 目录 上一章 绯雪转眼间就来到了无惘海底。她所在的地方似乎是一个密闭的空间,但是四周还能看到海里的鱼儿,而这个空间...
    迟安北阅读 173评论 0 3