项目地址: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回传给调用者
根据需求,在月日历下,会根据某接口返回的参数来标识当天是否有数据
此数据来源于网络请求,是异步操作,因此只能有调用者在网络请求返回之后将数据传入控件且刷新,与
返回今天和关闭相同,调用者最好不要直接操作控件视图,而通过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);
}
当点击返回今天时,需要做到:
- 回滚周/月视图至今天所在的周/月,后文就讨论具体实现
- 通知调用者重新选中今天
供日历视图调用的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("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)
视图层主要负责:
- 周视图渲染以及用户操作后对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) + "");
//找到这个月的第一天所在星期的周日
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);
}
}
}
}
}
关于仿小米日历的实现到此结束,祝各位天天开心,生活愉快!