Android日期显示和选择库实现原理

自己当初编写CalendarSelector库主要是为了解决日期的选择问题,比如说档期,一个人的档期大部分是一段连续的时间。
后来随着功能的完善,发现还可以很好的满足一些其它的需求,比如选择某几天,或者纯粹的显示。为了满足这些需求,自己进行了几个版本的迭代,在迭代中也解决了几个自己觉得比较棘手的问题,下面会分析自己的实现思路,具体的实现过程和进行的一些优化。

MonthView的绘制

View实现方式

MonthView是对月天数的组合显示,使得以月为整体来显示。自己最初的做法是MonthView为一个原始的View,通过canvas来绘制每一天,根据这个思路实现了一个版本,但最后被自己放弃。
因为如果想要实现一些动画的效果或者想自定义天的显示太麻烦了,只能通过canvas来绘制,坐标的计算太繁琐,也很容易出现误差~

ViewGroup实现方式

View实现方式被放弃之后,自己就在思考如何让绘制和增加一些动画能易于实现,并且方便第三方使用。自己最后选择了ViewGroup的方式,月为ViewGroup,而月的每一天都为
一个单独的View,通过组合的方式来实现一个月的显示,一个是自己不用管理天的绘制,而由于天的显示被抽象成View,添加动画,自定义自然会很方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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;
childView.layout(l, t,
r, b);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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跟当前需要显示的月份要求的高度不匹配,这个时候就需要requestLayout,重新measure、layout和draw。
但是呢当两者height相等时并不需要走这个流程,因为这个流程还是相当耗时的,为了针对这个进行优化,做了一些判断。

1
2
3
4
5
// when use in the recyclerview, each item's height may be different, we should requestLayout again
if(neededRelayout) {
requestLayout();
neededRelayout = false;
}

neededRelayout在计算月的天数时来进行判断,如果行相同,那么就不需要requestLayout()。

1
2
3
4
if(drawMonthDay) {
if(realRowCount != currentRealRowCount) neededRelayout = true;
realRowCount = currentRealRowCount;
}

在使用RecyclerView时减少一些对象的创建,对性能的改进还是明显的,可以减少gc的频率,降低内存抖动,有效的减少掉帧的情况出现。

DayViewInflater抽象的实现

当初自己构思DayViewInflater实现时,不得不惊讶于代码有结构的组织带来的效果真是大啊,通过对DayViewInflater抽象的实现,自己之前一直纠结的灵活自定义天的显示和选中、未选择中状态切换动画,变得是那么的简单,一切皆迎刃而解。

DayViewInflater

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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的功能做了区分,定义了两种模式,分别是选择一段连续的天多个不连续的天

1
2
3
4
public enum Mode{
INTERVAL,
SEGMENT
}

针对这两种模式分别有不同的实现,其实思路是一样的,只不过实现过程中的具体逻辑有一些微小的区别。

INTERVAL

1
2
3
4
5
6
7
8
9
10
11
12
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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一样的实现

1
2
3
4
5
6
7
8
9
10
11
12
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的刷新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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);
}

从上面的代码看的出来,在日期的选择过程中把一些拦截的功能交给了使用者,这样方便实现各种特殊的功能,灵活性相对来说比较高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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);
}
})

总结

上面谈到的几点基本上包含了CalendarSelector库最主要的功能点了,如果还有什么疑问的话,非常欢迎在GITHUB上提issue:)