項目地址:https://github.com/landscapeside/DragCalendar
先看效果圖:

根據(jù)效果,我們可以看到,要實現(xiàn)該控件,需要具備:
- 容器以及觸摸事件處理
- 周日歷布局以及選擇,切換上下周處理
- 月日歷布局以及選擇,切換上下月處理
首先說說容器
對于其他使用者來說,整個日歷都應(yīng)該是一個類似于RelativeLayout之類的容器,然后里面包含有我們需要的日歷控件,而且因為在滑動日歷的時候也會移動下面的listview或者scrollView部分,所以其實這是一個嵌套滑動控件,它必須能合理的處理不同的手勢場景(比如在listView的內(nèi)容滑動到頂部時再下滑會滑出日歷,以及上滑收起日歷等),所以它在設(shè)計上應(yīng)該類似于drawerLayout等手勢類容器,并且需要在滑動時通過滑動進度來動態(tài)設(shè)置動畫。
因此,容器控件需要做到:
- 觸摸事件的攔截與處理
- 周日歷和月日歷的收起和展開動畫
凡是觸摸事件處理與攔截都是通過復(fù)寫onInterceptTouchEvent和onTouchEvent來實現(xiàn),一般有如下方式:
- 邏輯全部寫在這兩個方法里,需要開發(fā)者自己去記錄位置坐標,計算方向等等,并且需要記錄若干狀態(tài)
- 用安卓提供的
GestureDetector類簡化某些常用手勢場景,當然依然需要復(fù)寫onInterceptTouchEvent和onTouchEvent,相對來說代碼簡化一些 - 用安卓提供的
ViewDragHelper類(以下簡稱VDH)來處理,該類更簡化,相對GestureDetector來說其增加了容器內(nèi)孩子視圖的判斷,正是因為這點,可以讓開發(fā)者方便的得知觸摸點由哪個孩子視圖處理,很大程度上避免處理坐標系信息
這里我們采用的方案主要是VDH,方案1和方案2為輔助,具體實現(xiàn)方式請參考用viewDragHelper來寫刷新控件一,用viewDragHelper來寫刷新控件二,用viewDragHelper來寫刷新控件三
接下來是代碼的架構(gòu)方面
更深入的分析需求,我們會發(fā)現(xiàn),容器類其實應(yīng)該負責(zé)處理內(nèi)容區(qū)與周日歷/月日歷的聯(lián)動,而至于日歷內(nèi)部渲染,邏輯處理應(yīng)該交由其他類來處理,這里我們再次細分:
- 邏輯部分(Presenter層):向日歷視圖發(fā)出刷新,定位命令,給調(diào)用者暴露關(guān)閉,返回今天方法,并且給調(diào)用者提供日期選中,滾動到某月/某周的消息回調(diào)
- 視圖部分(View層):從效果圖來看,周日歷/月日歷采用viewPager來實現(xiàn)即可,其重點負責(zé)日歷的渲染功能,同時當Presenter層被調(diào)用返回今天以及更新了數(shù)據(jù)源需要刷新時,最終需要視圖層來刷新頁面以及滑動到正確的周/月
邏輯部分Presenter
根據(jù)使用對象和場景,Presenter提供的能力分為三類:
- 供控件使用者調(diào)用的,屬于開放API部分
- 供視圖層調(diào)用,一般是用戶對視圖操作了之后由視圖通知Presenter做某事
- 消息回傳,視圖通知了Presenter之后,由Presenter來通知使用者來更新UI或者做其他事務(wù)
開放API
- 返回今天(供控件使用者調(diào)用)
- 關(guān)閉月日歷(供控件使用者調(diào)用)
- 設(shè)置數(shù)據(jù)源

開放API由使用者調(diào)用,因為根據(jù)效果圖來看,日歷的標題欄實為固定在界面頂部,正常情況下被toolBar所遮擋,滑動時逐步顯現(xiàn),因此將標題欄單獨實現(xiàn)為一個控件,所以需要日歷控件和標題欄控件互動:
- 標題欄上有兩個按鈕,返回今天和收起,點擊后應(yīng)該通知日歷控件做相應(yīng)操作
- 日歷控件滑動或者選擇后,可能會導(dǎo)致標題欄上文字顯示改變,因此需要日歷控件提供回調(diào)
從代碼實現(xiàn)角度,由于這些操作實際為邏輯控制部分,因此應(yīng)該交由presenter來實現(xiàn),調(diào)用者應(yīng)該通過presenter作為橋梁來操作控件視圖,且控件視圖回調(diào)的消息通過presenter回傳給調(diào)用者
根據(jù)需求,在月日歷下,會根據(jù)某接口返回的參數(shù)來標識當天是否有數(shù)據(jù)

此數(shù)據(jù)來源于網(wǎng)絡(luò)請求,是異步操作,因此只能有調(diào)用者在網(wǎng)絡(luò)請求返回之后將數(shù)據(jù)傳入控件且刷新,與
返回今天和關(guān)閉相同,調(diào)用者最好不要直接操作控件視圖,而通過presenter作為橋接,間接通知視圖刷新頁面,
使得調(diào)用者與視圖解耦,將日歷視圖具體實現(xiàn)邏輯隱蔽起來。幾個主要代碼實現(xiàn)如下:
// 設(shè)置數(shù)據(jù)源
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();
}
// 關(guān)閉月日歷
public void close() {
viewBuilder().dragCalendarLayout.setExpand(false);
}
當點擊返回今天時,需要做到:
- 回滾周/月視圖至今天所在的周/月,后文就討論具體實現(xiàn)
- 通知調(diào)用者重新選中今天
供日歷視圖調(diào)用的presenter接口,一般為通知調(diào)用者進行業(yè)務(wù)處理
根據(jù)設(shè)計,日歷視圖有如下幾個會引發(fā)調(diào)用者業(yè)務(wù)處理的操作:
- 周日歷下,左右滑動切換會導(dǎo)致日期的自動切換,比如選中日期為周二且滑至上一周時,同時日期切換至該周周二
- 月日歷下,左右滑動切換日歷標題欄上展示日期
- 月日歷下標題欄展示日期或者選擇日期非今日,展示返回今日按鈕
之前說過,周日歷/月日歷實際為viewPager實現(xiàn),因此要實現(xiàn)滑動切換邏輯只需監(jiān)聽ViewPager.OnPageChangeListener,因月日歷和周日歷的實際實現(xiàn)不同,這里用枚舉CalendarPagerChangeEnum來區(qū)分:
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提供選擇日期和設(shè)置月日歷滾動日期接口:
// 設(shè)置選中日期并觸發(fā)消息回傳,通知調(diào)用者進行業(yè)務(wù)處理
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();
}
}
// 月日歷下當前滾動到某月時的日期設(shè)置
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);
}
}
消息回傳通知
根據(jù)之前的約定,調(diào)用者只與presenter交互,同樣的,presenter接受到日歷視圖的操作后,由presenter通知調(diào)用者進行業(yè)務(wù)處理
// 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);
}
此處在設(shè)置消息通知接口時需強制觸發(fā)消息一次,目的是為了在初始階段刷新日歷標題欄
視圖部分(VIEW)
視圖層主要負責(zé):
- 周視圖渲染以及用戶操作后對presenter發(fā)起消息通知
- 月視圖渲染以及用戶操作后對presenter發(fā)起消息通知
從結(jié)構(gòu)上來說,兩者都是采用viewPager實現(xiàn),不同點即其渲染方式不同,因此這里也可采用枚舉CalendarType加以區(qū)分:
public enum CalendarType implements IAdapterRefresh,IAdapterConstant {
MONTH {
@Override
public void refresh(ViewGroup view, int position) {
//給view 填充內(nèi)容
//設(shè)置開始時間為本周日
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 填充內(nèi)容
//設(shè)置開始時間為本周日
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中只需處理邏輯部分,這里為計算出每周/月上起始時間(這里的起始時間并非每一周/月的第一天,而應(yīng)該是每一張周卡片/月卡片第一行第一列開始的那個日期,因日歷橫向是從周日開始,所以只需算出第一行的周日即可),并調(diào)用相應(yīng)的周/月視圖進行渲染。而周/月視圖來源于不同的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個視圖循環(huán)使用達到節(jié)省資源的目的,WeekCard實現(xiàn)了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();
}
}
我們會發(fā)現(xiàn),因為ViewPager以及包含它的容器為動態(tài)實例化,因此需要手動的設(shè)置高度而無法用系統(tǒng)的wrap_content屬性,因此這里需要開發(fā)者自我計算一個合理的高度WEEK_HEIGHT,此處作者設(shè)置每周高45dp,一個月最高為305dp(6行的周加上上下邊距總共305dp,詳細設(shè)置見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;
}
月日歷實現(xiàn)與之類似,就不贅述。
另外,在前述的presenter實現(xiàn)中,提到返回今日時需要通時回滾周/月日歷視圖到當前周/月,其實際為相應(yīng)的ViewPager重設(shè)當前頁,因此在前述的presenter的backToday實現(xiàn)中調(diào)用的viewBuilder().dragCalendarLayout.backToday();實際上是調(diào)用周視圖WeekView的weekPager.setCurrentItem(CalendarType.WEEK.defPosition(), true);以及月視圖MonthView的monthPager.setCurrentItem(CalendarType.MONTH.defPosition(), true);
周,月視圖渲染實現(xiàn)
周卡片的渲染,實際上只需要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);
}
}
}
}
}
關(guān)于仿小米日歷的實現(xiàn)到此結(jié)束,祝各位天天開心,生活愉快!