美文网首页功能控件精选案例View
实现一个仿小米日历控件

实现一个仿小米日历控件

作者: 岸边的风景 | 来源:发表于2017-01-11 16:12 被阅读1361次

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

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

    相关文章

      网友评论

      • 8a9d7c6a7d5d:您好,月/周视图怎样让他无限循环啊
        8a9d7c6a7d5d:@岸边的风景 谢谢
        岸边的风景:@Glide风 conf下面的calendarType就是配置,里面的defPosition就是默认viewpager的position,你调整这个与getCount即可
      • 香沙小熊:github 中MonthCard.java 中变量和资源文件不全,你能修改一下吗?
        香沙小熊:最好能把文件 建成一个仓库
        香沙小熊:@岸边的风景 好了说一声
        岸边的风景: @阳光小熊 👌
      • 香沙小熊:MonthCard.java 在github类中获取不到
      • 大白PM:技术文章还是发在cnblog比较好。写多好在这都没啥关注度,没啥程序员😂
        岸边的风景: @大白PM 嗯,多谢提醒

      本文标题:实现一个仿小米日历控件

      本文链接:https://www.haomeiwen.com/subject/pwxxbttx.html