自己当初编写CalendarSelector库主要是为了解决日期的选择
问题,比如说档期(一个人的档期大部分是一段连续的时间)。
后来随着功能的完善,发现还可以很好的满足一些其它的需求,比如选择某几天,或者纯粹的显示某个月。为了满足这些需求,自己进行了几个版本的迭代,在迭代中也解决了几个自己觉得比较棘手的问题。下面会分析自己的实现思路,具体的实现过程和进行的一些优化。
MonthView的绘制
View实现方式
MonthView是对月天数的组合显示,使得以月为整体来展示。自己最初的做法是MonthView为一个原始的View,通过Canvas来绘制每一天,根据这个思路实现了一个版本,但最后被自己放弃。因为如果想要实现一些动画的效果或者想自定义天的显示太麻烦了,只能通过Canvas来绘制,坐标的计算太繁琐了,也很容易出现误差~
ViewGroup实现方式
View实现方式被放弃之后,自己就在思考如何让绘制和增加一些动画能易于实现,并且方便第三方使用。自己最后选择了ViewGroup的方式,月为ViewGroup,而月的每一天都为一个单独的View,通过组合的方式来实现一个月的显示,一个是自己不用管理天的绘制,而由于天的显示被抽象成View,添加动画,自定义自然会很方便。
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
for (int index = 0, count = getChildCount();
index < count; index++){
View childView = getChildAt(index);
int row = index / COL_COUNT;
int col = index - row * COL_COUNT;
int l = col * dayWidth;
int t = row * dayHeight;
int r = l + dayWidth;
int b = t + dayHeight;
// layout day view
childView.layout(l, t,
r, b);
}
}
private void createDayViews() {
for (int row = 0; row < ROW_COUNT; row++){
for (int col = 0; col < COL_COUNT; col++){
DayViewHolder dayViewHolder = dayInflater.inflateDayView(this);
View dayView = dayViewHolder.getDayView();
dayView.setLayoutParams(new ViewGroup.LayoutParams(
dayWidth,
dayHeight));
addView(dayView);
dayViewHolders[row][col] = dayViewHolder;
drawDays(row, col, dayView);
dayView.setClickable(true);
final int clickRow = row;
final int clickCol = col;
dayView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
measureClickCell(clickRow, clickCol);
}
});
}
}
}
针对RecyclerView的优化
由于每个月的天数存在不相等的情况,所以当把MonthView嵌入到RecyclerView中时会存在一些小问题。由于RecyclerView回收机制的存在,有可能存在回收使用的MonthView的height跟当前需要显示的月份要求的height不匹配,这个时候就需要requestLayout,重新measure、layout和draw。
但是呢当两者height相等时并不需要走这个流程,因为这个流程还是相当耗时的,为了针对这个进行优化,做了一些判断。
// when use in the recyclerview, each item's height may be different, we should requestLayout again
if(neededRelayout) {
requestLayout();
neededRelayout = false;
}
neededRelayout在计算月的天数时来进行判断,如果行相同,那么就不需要requestLayout()。
if(drawMonthDay) {
if(realRowCount != currentRealRowCount) neededRelayout = true;
realRowCount = currentRealRowCount;
}
在使用RecyclerView时减少一些对象的创建,对性能的改进还是明显的,可以减少gc的频率,降低内存抖动,有效的减少掉帧的情况出现。
DayViewInflater抽象的实现
当初自己构思DayViewInflater实现时,不得不惊讶于代码有结构的组织带来的效果真是大啊,通过对DayViewInflater抽象的实现,自己之前一直纠结的灵活自定义天的显示和选中、未选择中状态切换动画,变得是那么的简单,一切皆迎刃而解。
DayViewInflater
public abstract class DayViewInflater {
protected Context mContext;
protected LayoutInflater mLayoutInflater;
public DayViewInflater(Context context){
mContext = context;
mLayoutInflater = LayoutInflater.from(mContext);
}
/**
* inflate day view
* @param container MonthView
* @return day view
*/
public abstract DayViewHolder inflateDayView(ViewGroup container);
public Decor inflateHorizontalDecor(ViewGroup container, int row, int totalRow){
return null;
}
public Decor inflateVerticalDecor(ViewGroup container, int col, int totalCol){
return null;
}
protected int dip2px(Context context, float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
public static class Decor{
private boolean showDecor = false;
private View decorView;
public Decor(View decorView){
this(decorView, false);
}
public Decor(View decorView, boolean showDecor){
this.decorView = decorView;
this.showDecor = showDecor;
}
public View getDecorView() {
return decorView;
}
public boolean isShowDecor() {
return showDecor;
}
}
}
DayViewHolder
public abstract class DayViewHolder {
protected Context mContext;
protected View dayView;
public DayViewHolder(View dayView){
this.dayView = dayView;
mContext = dayView.getContext();
}
public View getDayView() {
return dayView;
}
public abstract void setCurrentMonthDayText(FullDay day, boolean isSelected);
public abstract void setPrevMonthDayText(FullDay day);
public abstract void setNextMonthDayText(FullDay day);
}
通过DayViewInflater和DayViewHolder的组合,让天的UI自定义非常的方便,而状态切换的动画也更加的方便实现。
AnimDayViewInflater.java (自定义DayViewInflater)
public class AnimDayViewInflater extends DayViewInflater{
public AnimDayViewInflater(Context context) {
super(context);
}
@Override
public DayViewHolder inflateDayView(ViewGroup container) {
View dayView = mLayoutInflater.inflate(R.layout.layout_dayview_custom, container, false);
return new CustomDayViewHolder(dayView);
}
public static class CustomDayViewHolder extends DayViewHolder{
protected TextView tvDay;
private int mPrevMonthDayTextColor;
private int mNextMonthDayTextColor;
public CustomDayViewHolder(View dayView) {
super(dayView);
tvDay = (TextView) dayView.findViewById(com.tubb.calendarselector.library.R.id.tvDay);
mPrevMonthDayTextColor = ContextCompat.getColor(mContext, com.tubb.calendarselector.library.R.color.c_999999);
mNextMonthDayTextColor = ContextCompat.getColor(mContext, com.tubb.calendarselector.library.R.color.c_dddddd);
}
@Override
public void setCurrentMonthDayText(FullDay day, boolean isSelected) {
boolean oldSelected = tvDay.isSelected();
tvDay.setText(String.valueOf(day.getDay()));
tvDay.setSelected(isSelected);
// selected animation
if(!oldSelected && isSelected){
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.setInterpolator(AnimationUtils.loadInterpolator(mContext, android.R.anim.bounce_interpolator));
animatorSet.play(ObjectAnimator.ofFloat(tvDay, "scaleX", 0.5f, 1.0f))
.with(ObjectAnimator.ofFloat(tvDay, "scaleY", 0.5f, 1.0f));
animatorSet.setDuration(500)
.start();
}
}
@Override
public void setPrevMonthDayText(FullDay day) {
tvDay.setTextColor(mPrevMonthDayTextColor);
tvDay.setText(String.valueOf(day.getDay()));
}
@Override
public void setNextMonthDayText(FullDay day) {
tvDay.setTextColor(mNextMonthDayTextColor);
tvDay.setText(String.valueOf(day.getDay()));
}
}
}
SingleMonthSelector和CalendarSelector分析
其实CalendarSelector库的核心功能由这两个类来实现的,自己的初衷也是为了实现select的功能,下面简要的介绍下实现原理。
SingleMonthSelector
首先对select的功能做了区分,定义了两种模式,分别是选择一段连续的天
和多个不连续的天
。
public enum Mode{
INTERVAL,
SEGMENT
}
针对这两种模式分别有不同的实现,其实思路是一样的,只不过实现过程中的具体逻辑有一些微小的区别。
INTERVAL模式主要是用来选择多个不连续
的日期,通过用List来保存当前选中的日期,实现逻辑比较简单。
protected void intervalSelect(MonthView monthView, FullDay day) {
if(monthView.getSelectedDays().contains(day)) {
monthView.removeSelectedDay(day);
sDays.remove(day);
if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
} else {
if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
monthView.addSelectedDay(day);
sDays.add(day);
}
intervalSelectListener.onIntervalSelect(sDays);
}
SEGMENT模式主要是用来选择一段连续
的日期,通过记录开始选中的日期和结束选中的日期来确定,实现逻辑比较复杂,因为自己想让用户可以取消和重复选择,这样就会有很多的判断在里面,复杂度明显增加了。
private void segmentSelect(MonthView monthView, FullDay ssDay) {
if(segmentSelectListener.onInterceptSelect(ssDay)) return;
if(startSelectedRecord.day == null && endSelectedRecord.day == null){ // init status
startSelectedRecord.day = ssDay;
monthView.addSelectedDay(ssDay);
}else if(endSelectedRecord.day == null){ // start day is ok, but end day not
if(startSelectedRecord.day.getDay() != ssDay.getDay()){
if(startSelectedRecord.day.getDay() < ssDay.getDay()){
if(segmentSelectListener.onInterceptSelect(startSelectedRecord.day, ssDay)) return;
for (int day = startSelectedRecord.day.getDay(); day <= ssDay.getDay(); day++){
monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
}
endSelectedRecord.day = ssDay;
}else if(startSelectedRecord.day.getDay() > ssDay.getDay()){
if(segmentSelectListener.onInterceptSelect(ssDay, startSelectedRecord.day)) return;
for (int day = ssDay.getDay(); day <= startSelectedRecord.day.getDay(); day++){
monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
}
endSelectedRecord.day = startSelectedRecord.day;
startSelectedRecord.day = ssDay;
}
segmentSelectListener.onSegmentSelect(startSelectedRecord.day, endSelectedRecord.day);
}else{
// selected the same day when the end day is not selected
segmentSelectListener.selectedSameDay(ssDay);
monthView.clearSelectedDays();
startSelectedRecord.reset();
endSelectedRecord.reset();
}
}else { // start day and end day is ok
monthView.clearSelectedDays();
monthView.addSelectedDay(ssDay);
startSelectedRecord.day = ssDay;
endSelectedRecord.reset();
}
}
CalendarSelector
CalendarSelector的实现相对于SingleMonthSelector的实现要复杂一些,因为要跨MonthView来选择,但是实现的思路跟SingleMonthSelector是一样的,只不过是多了一些判断。
CalendarSelector也有两种模式,这个跟SingleMonthSelector是一样的。
INTERVAL模式跟SingleMonthSelector一样的实现
protected void intervalSelect(MonthView monthView, FullDay day) {
if(monthView.getSelectedDays().contains(day)) {
monthView.removeSelectedDay(day);
sDays.remove(day);
if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
} else {
if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
monthView.addSelectedDay(day);
sDays.add(day);
}
intervalSelectListener.onIntervalSelect(sDays);
}
SEGMENT模式稍微复杂一些,主要是一些状态的判断,还有MonthView的刷新逻辑。
private void segmentSelect(ViewGroup container, MonthView monthView, FullDay ssDay, int position) {
if(segmentSelectListener.onInterceptSelect(ssDay)) return;
if(!startSelectedRecord.isRecord() && !endSelectedRecord.isRecord()){ // init status
startSelectedRecord.position = position;
startSelectedRecord.day = ssDay;
monthView.addSelectedDay(ssDay);
}else if(startSelectedRecord.isRecord() && !endSelectedRecord.isRecord()){ // start day is ok, but end day not
if(startSelectedRecord.position < position){ // click later month
if(segmentSelectListener.onInterceptSelect(startSelectedRecord.day, ssDay)) return;
endSelectedRecord.position = position;
endSelectedRecord.day = ssDay;
segmentMonthSelected(container);
}else if(startSelectedRecord.position > position){ // click before month
if(segmentSelectListener.onInterceptSelect(ssDay, startSelectedRecord.day)) return;
endSelectedRecord.position = startSelectedRecord.position;
endSelectedRecord.day = startSelectedRecord.day;
startSelectedRecord.position = position;
startSelectedRecord.day = ssDay;
segmentMonthSelected(container);
}else{ // click the same month
if(startSelectedRecord.day.getDay() != ssDay.getDay()){
if(startSelectedRecord.day.getDay() < ssDay.getDay()){
if(segmentSelectListener.onInterceptSelect(startSelectedRecord.day, ssDay)) return;
for (int day = startSelectedRecord.day.getDay(); day <= ssDay.getDay(); day++){
monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
}
endSelectedRecord.position = position;
endSelectedRecord.day = ssDay;
}else if(startSelectedRecord.day.getDay() > ssDay.getDay()){
if(segmentSelectListener.onInterceptSelect(ssDay, startSelectedRecord.day)) return;
for (int day = ssDay.getDay(); day <= startSelectedRecord.day.getDay(); day++){
monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
}
endSelectedRecord.position = position;
endSelectedRecord.day = startSelectedRecord.day;
startSelectedRecord.day = ssDay;
}
monthView.invalidate();
segmentSelectListener.onSegmentSelect(startSelectedRecord.day, endSelectedRecord.day);
}else{
// selected the same day when the end day is not selected
segmentSelectListener.selectedSameDay(ssDay);
monthView.clearSelectedDays();
startSelectedRecord.reset();
endSelectedRecord.reset();
}
}
}else if(startSelectedRecord.isRecord() && endSelectedRecord.isRecord()){ // start day and end day is ok
dataList.get(startSelectedRecord.position).getSelectedDays().clear();
invalidate(container, startSelectedRecord.position);
dataList.get(endSelectedRecord.position).getSelectedDays().clear();
invalidate(container, endSelectedRecord.position);
int startSelectedPosition = startSelectedRecord.position;
int endSelectedPosition = endSelectedRecord.position;
if(endSelectedPosition - startSelectedPosition > 1){
do {
startSelectedPosition++;
dataList.get(startSelectedPosition).getSelectedDays().clear();
invalidate(container, startSelectedPosition);
}while (startSelectedPosition < endSelectedPosition);
}
startSelectedRecord.position = position;
startSelectedRecord.day = ssDay;
dataList.get(startSelectedRecord.position).addSelectedDay(startSelectedRecord.day);
invalidate(container, position);
endSelectedRecord.reset();
}
}
private void invalidate(ViewGroup container, int position){
if(position >= 0) {
View childView = container.getChildAt(position);
if(childView == null){
if(container instanceof RecyclerView){
RecyclerView rv = (RecyclerView)container;
rv.getAdapter().notifyItemChanged(position);
}else{
Log.e(TAG, "the container view is not expected ViewGroup");
}
}else{
List<View> unvisited = new ArrayList<>();
unvisited.add(childView);
while (!unvisited.isEmpty()) {
View child = unvisited.remove(0);
if (!(child instanceof ViewGroup)) {
continue;
}
ViewGroup group = (ViewGroup) child;
if(group instanceof MonthView){
MonthView monthView = (MonthView) group;
monthView.refresh();
break;
}
final int childCount = group.getChildCount();
for (int i=0; i<childCount; i++) unvisited.add(group.getChildAt(i));
}
}
}
}
private void segmentMonthSelected(ViewGroup container) {
SCMonth startMonth = dataList.get(startSelectedRecord.position);
int startSelectedMonthDayCount = SCDateUtils.getDayCountOfMonth(startMonth.getYear(), startMonth.getMonth());
for (int day = startSelectedRecord.day.getDay(); day <= startSelectedMonthDayCount; day++){
startMonth.addSelectedDay(new FullDay(startMonth.getYear(), startMonth.getMonth(), day));
}
invalidate(container, startSelectedRecord.position);
int startSelectedPosition = startSelectedRecord.position;
int endSelectedPosition = endSelectedRecord.position;
while (endSelectedPosition - startSelectedPosition > 1){
startSelectedPosition++;
SCMonth segmentMonth = dataList.get(startSelectedPosition);
int segmentSelectedMonthDayCount = SCDateUtils.getDayCountOfMonth(segmentMonth.getYear(), segmentMonth.getMonth());
for (int day = 1; day <= segmentSelectedMonthDayCount; day++) {
segmentMonth.addSelectedDay(new FullDay(segmentMonth.getYear(), segmentMonth.getMonth(), day));
}
invalidate(container, startSelectedPosition);
}
SCMonth endMonth = dataList.get(endSelectedRecord.position);
for (int day = 1; day <= endSelectedRecord.day.getDay(); day++){
endMonth.addSelectedDay(new FullDay(endMonth.getYear(), endMonth.getMonth(), day));
}
invalidate(container, endSelectedRecord.position);
segmentSelectListener.onSegmentSelect(startSelectedRecord.day, endSelectedRecord.day);
}
从上面的代码看的出来,在日期的选择过程中把一些拦截的功能交给了使用者,这样方便实现各种特殊的功能,灵活性相对来说比较高。
selector = new CalendarSelector(data, CalendarSelector.Mode.SEGMENT);
selector.setSegmentSelectListener(new SegmentSelectListener() {
@Override
public void onSegmentSelect(FullDay startDay, FullDay endDay) {
Log.d(TAG, "segment select " + startDay.toString() + " : " + endDay.toString());
}
@Override
public boolean onInterceptSelect(FullDay selectingDay) { // one day intercept
if(SCDateUtils.isToday(selectingDay.getYear(), selectingDay.getMonth(), selectingDay.getDay())){
Toast.makeText(CalendarSelectorActivity.this, "Today can't be selected", Toast.LENGTH_SHORT).show();
return true;
}
return super.onInterceptSelect(selectingDay);
}
@Override
public boolean onInterceptSelect(FullDay startDay, FullDay endDay) { // segment days intercept
int differDays = SCDateUtils.countDays(startDay.getYear(), startDay.getMonth(), startDay.getDay(),
endDay.getYear(), endDay.getMonth(), endDay.getDay());
Log.d(TAG, "differDays " + differDays);
if(differDays > 10) {
Toast.makeText(CalendarSelectorActivity.this, "Selected days can't more than 10", Toast.LENGTH_SHORT).show();
return true;
}
return super.onInterceptSelect(startDay, endDay);
}
@Override
public void selectedSameDay(FullDay sameDay) { // selected the same day
super.selectedSameDay(sameDay);
}
})
Selector的自定义
有些朋友可能会说,如果不想使用默认的SingleMonthSelector和CalendarSelector该怎么办,其实不用担心,完全可以自定义一个Selector,实现起来不复杂,因为自己把天的click事件和MonthView的刷新逻辑(某天被选中之后UI的改变)都暴露出来了,根据自己的逻辑使用即可。
总结
上面谈到的几点基本上包含了CalendarSelector库最主要的功能点了,如果还有什么疑问的话,非常欢迎在GITHUB上提issue:)