項目地址: https://github.com/landscapeside/DragCalendar
先看效果圖:
效果圖
根據效果,我們可以看到,要實現該控件,需要具備:
- 容器以及觸摸事件處理
- 周日歷布局以及選擇,切換上下周處理
- 月日歷布局以及選擇,切換上下月處理
首先說說容器
對于其他使用者來說,整個日歷都應該是一個類似于RelativeLayout之類的容器,然后里面包含有我們需要的日歷控件,而且因為在滑動日歷的時候也會移動下面的listview或者scrollView部分,所以其實這是一個嵌套滑動控件,它必須能合理的處理不同的手勢場景(比如在listView的內容滑動到頂部時再下滑會滑出日歷,以及上滑收起日歷等),所以它在設計上應該類似于drawerLayout等手勢類容器,并且需要在滑動時通過滑動進度來動態設置動畫。
因此,容器控件需要做到:
- 觸摸事件的攔截與處理
- 周日歷和月日歷的收起和展開動畫
凡是觸摸事件處理與攔截都是通過復寫 onInterceptTouchEvent
和 onTouchEvent
來實現,一般有如下方式:
- 邏輯全部寫在這兩個方法里,需要開發者自己去記錄位置坐標,計算方向等等,并且需要記錄若干狀態
- 用安卓提供的
GestureDetector
類簡化某些常用手勢場景,當然依然需要復寫onInterceptTouchEvent
和onTouchEvent
,相對來說代碼簡化一些 - 用安卓提供的
ViewDragHelper
類(以下簡稱VDH)來處理,該類更簡化,相對GestureDetector
來說其增加了容器內孩子視圖的判斷,正是因為這點,可以讓開發者方便的得知觸摸點由哪個孩子視圖處理,很大程度上避免處理坐標系信息
這里我們采用的方案主要是VDH,方案1和方案2為輔助,具體實現方式請參考 用viewDragHelper來寫刷新控件一 , 用viewDragHelper來寫刷新控件二 , 用viewDragHelper來寫刷新控件三
接下來是代碼的架構方面
更深入的分析需求,我們會發現,容器類其實應該負責處理內容區與周日歷/月日歷的聯動,而至于日歷內部渲染,邏輯處理應該交由其他類來處理,這里我們再次細分:
- 邏輯部分(Presenter層):向日歷視圖發出刷新,定位命令,給調用者暴露關閉,返回今天方法,并且給調用者提供日期選中,滾動到某月/某周的消息回調
- 視圖部分(View層):從效果圖來看,周日歷/月日歷采用viewPager來實現即可,其重點負責日歷的渲染功能,同時當Presenter層被調用返回今天以及更新了數據源需要刷新時,最終需要視圖層來刷新頁面以及滑動到正確的周/月
邏輯部分Presenter
根據使用對象和場景,Presenter提供的能力分為三類:
- 供控件使用者調用的,屬于開放API部分
- 供視圖層調用,一般是用戶對視圖操作了之后由視圖通知Presenter做某事
- 消息回傳,視圖通知了Presenter之后,由Presenter來通知使用者來更新UI或者做其他事務
開放API
- 返回今天(供控件使用者調用)
- 關閉月日歷(供控件使用者調用)
- 設置數據源
示例圖
開放API由使用者調用,因為根據效果圖來看,日歷的標題欄實為固定在界面頂部,正常情況下被toolBar所遮擋,滑動時逐步顯現,因此將標題欄單獨實現為一個控件,所以需要日歷控件和標題欄控件互動:
- 標題欄上有兩個按鈕,返回今天和收起,點擊后應該通知日歷控件做相應操作
- 日歷控件滑動或者選擇后,可能會導致標題欄上文字顯示改變,因此需要日歷控件提供回調
從代碼實現角度,由于這些操作實際為邏輯控制部分,因此應該交由presenter來實現,調用者應該通過presenter作為橋梁來操作控件視圖,且控件視圖回調的消息通過presenter回傳給調用者
根據需求,在月日歷下,會根據某接口返回的參數來標識當天是否有數據
month_card.png
此數據來源于網絡請求,是異步操作,因此只能有調用者在網絡請求返回之后將數據傳入控件且刷新,與
返回今天和關閉相同,調用者最好不要直接操作控件視圖,而通過presenter作為橋接,間接通知視圖刷新頁面,
使得調用者與視圖 解耦 ,將日歷視圖具體實現邏輯隱蔽起來。幾個主要代碼實現如下:
// 設置數據源 public lt;Tgt; void parseData(Listlt;Tgt; sources) { if (calendarDotVO == null) { throw new IllegalArgumentException(quot;Dot Data must not be nullquot;); } calendarDotVO.parseData(sources); viewBuilder().dragCalendarLayout.reDraw(); } // 返回今天 public void backToday() { setSelectTime(todayTime); viewBuilder().dragCalendarLayout.backToday(); } // 關閉月日歷 public void close() { viewBuilder().dragCalendarLayout.setExpand(false); }
當點擊返回今天時,需要做到:
- 回滾周/月視圖至今天所在的周/月,后文就討論具體實現
- 通知調用者重新選中今天
供日歷視圖調用的presenter接口,一般為通知調用者進行業務處理
根據設計,日歷視圖有如下幾個會引發調用者業務處理的操作:
- 周日歷下,左右滑動切換會導致日期的自動切換,比如選中日期為周二且滑至上一周時,同時日期切換至該周周二
- 月日歷下,左右滑動切換日歷標題欄上展示日期
- 月日歷下標題欄展示日期或者選擇日期非今日,展示返回今日按鈕
之前說過,周日歷/月日歷實際為 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(quot;selectTime can not be emptyquot;); } if (DateUtils.diff(todayTime, selectTime) lt; 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(quot;currentScrollDate can not be emptyquot;); } 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)
視圖層主要負責:
- 周視圖渲染以及用戶操作后對presenter發起消息通知
- 月視圖渲染以及用戶操作后對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) quot;quot;); //找到這個月的第一天所在星期的周日 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 Listlt;Viewgt; views = new ArrayListlt;gt;(); WeekCard currentCard; public WeekCalendarAdapter(Context context) { views.clear(); for (int i = 0; i lt; 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 lt; 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 -gt; 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 quot;quot;); if (DateUtils.diff(CalendarPresenter.instance().today(), DateUtils.getTagTimeStr(today)) gt;= 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 lt; 6; b ) { final ViewGroup view = (ViewGroup) monthContent.getChildAt(b); int currentMonth = today.get(Calendar.MONTH); if (pageMonth != currentMonth amp;amp; b != 0) { view.setVisibility(INVISIBLE); today.add(Calendar.DATE, 7); } else { view.setVisibility(VISIBLE); for (int a = 0; a lt; 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 quot;quot;); dayOfWeek.setTag(DateUtils.getTagTimeStr(today)); dayOfWeek.setOnClickListener(v -gt; 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)) gt;= 0) { renderNormal(dayOfWeek, DateUtils.getTagTimeStr(today)); } else { renderGray(dayOfWeek, DateUtils.getTagTimeStr(today)); } } today.add(Calendar.DATE, 1); } } } } }
關于仿小米日歷的實現到此結束,祝各位天天開心,生活愉快!
Tags: 安卓開發
文章來源:http://www.jianshu.com/p/f9bf72938d64