背景
前段时间公司测试给我提了一个bug:在日期选择框弹出来的时候,显示出了未来1个月的日期,如下所示:
需求是说用户无法选择今天以后的日期,所以要将未来的日期给隐藏掉。
探索
所以,我立刻去查看了下自己的代码:
long nowTime = System.currentTimeMillis();
mBinding.dataPicker.setMinDate(DateTimeUtils.formatDateString("2000-01-01"));
mBinding.dataPicker.setMaxDate(nowTime);
获取当前的时间,然后将当前的时间设置为最大日期。看了一遍似乎没有多大问题,那么为什么会多了一个月的日期显示呢。
怎么办呢?那就查看源码吧。
既然日期选择框显示了未来一个月的日期,那么先去查看下这个DataPicker是怎么绘制出来的吧
switch (mMode) {
case MODE_CALENDAR:
mDelegate = createCalendarUIDelegate(context, attrs, defStyleAttr, defStyleRes);
break;
case MODE_SPINNER:
default:
mDelegate = createSpinnerUIDelegate(context, attrs, defStyleAttr, defStyleRes);
break;
}
在DataPicker的构造函数里面初始化了Delegate,我们没有设置属性,那就是默认的DatePickerSpinnerDelegate实现类。既然是代理,那么后续的操作应该都是在delegate实现类里面做了,那么进入DatePickerSpinnerDelegate一探究竟。
DatePickerSpinnerDelegate(DatePicker delegator, Context context, AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
...代码省略...
// day
mDaySpinner = (NumberPicker) mDelegator.findViewById(com.android.internal.R.id.day);
mDaySpinner.setFormatter(NumberPicker.getTwoDigitFormatter());
mDaySpinner.setOnLongPressUpdateInterval(100);
mDaySpinner.setOnValueChangedListener(onChangeListener);
mDaySpinnerInput = (EditText) mDaySpinner.findViewById(com.android.internal.R.id.numberpicker_input);
// month
mMonthSpinner = (NumberPicker) mDelegator.findViewById(com.android.internal.R.id.month);
mMonthSpinner.setMinValue(0);
mMonthSpinner.setMaxValue(mNumberOfMonths - 1);
mMonthSpinner.setDisplayedValues(mShortMonths);
mMonthSpinner.setOnLongPressUpdateInterval(200);
mMonthSpinner.setOnValueChangedListener(onChangeListener);
mMonthSpinnerInput = (EditText) mMonthSpinner.findViewById(com.android.internal.R.id.numberpicker_input);
// year
mYearSpinner = (NumberPicker) mDelegator.findViewById(com.android.internal.R.id.year);
mYearSpinner.setOnLongPressUpdateInterval(100);
mYearSpinner.setOnValueChangedListener(onChangeListener);
...代码省略...
}
一眼扫过来,发现了这三个spinner,看名字应该就是弹窗上面显示的年月日的控件。这里显示了未来一个月的日期,那我们就只关心mMonthSpinner是怎么绘制出来的吧,去查看下这个对象里面的onDraw方法
protected void onDraw(Canvas canvas) {
...代码省略...
// draw the selector wheel
int[] selectorIndices = mSelectorIndices;
for (int i = 0; i < selectorIndices.length; i++) {
int selectorIndex = selectorIndices[i];
String scrollSelectorValue = mSelectorIndexToStringCache.get(selectorIndex);
// Do not draw the middle item if input is visible since the input
// is shown only if the wheel is static and it covers the middle
// item. Otherwise, if the user starts editing the text via the
// IME he may see a dimmed version of the old value intermixed
// with the new one.
if ((showSelectorWheel && i != SELECTOR_MIDDLE_ITEM_INDEX) ||
(i == SELECTOR_MIDDLE_ITEM_INDEX && mInputText.getVisibility() != VISIBLE)) {
canvas.drawText(scrollSelectorValue, x, y, mSelectorWheelPaint);
}
y += mSelectorElementHeight;
}
...代码省略...
}
同样我们也只找重点(找drawText即可),发现是mSelectorIndices[]这个数组决定的要绘制的月份。那整个类里面搜索一下这个数组什么时候被赋值的
/**
* Resets the selector indices and clear the cached string representation of
* these indices.
*/
private void initializeSelectorWheelIndices() {
mSelectorIndexToStringCache.clear();
int[] selectorIndices = mSelectorIndices;
int current = getValue();
for (int i = 0; i < mSelectorIndices.length; i++) {
int selectorIndex = current + (i - SELECTOR_MIDDLE_ITEM_INDEX);
if (mWrapSelectorWheel) {
selectorIndex = getWrappedSelectorIndex(selectorIndex);
}
selectorIndices[i] = selectorIndex;
ensureCachedScrollSelectorValue(selectorIndices[i]);
}
}
搜了一圈发现只在这个方法里面被赋值过,然后在查看下这个方法的调用地方
找到了setMaxValue方法,看来似乎离真相越来越近了,那么这个setMaxValue到底做了什么呢?接着往下看
public void setMaxValue(int maxValue) {
if (mMaxValue == maxValue) {
return;
}
if (maxValue < 0) {
throw new IllegalArgumentException("maxValue must be >= 0");
}
mMaxValue = maxValue;
if (mMaxValue < mValue) {
mValue = mMaxValue;
}
updateWrapSelectorWheel();
initializeSelectorWheelIndices();
updateInputTextView();
tryComputeMaxWidth();
invalidate();
}
这个setMaxValue只是把maxValue值设置了进来,然后在initializeSelectorWheelIndices对数组进行了赋值,看来还是得往更上层找,这个maxValue值到底怎么传的。
因为我们现在设置的是日期,那么必然就看DatePickerSpinnerDelegatede#updateSpinner就好了。
private void updateSpinners() {
// set the spinner ranges respecting the min and max dates
if (mCurrentDate.equals(mMinDate)) {
mDaySpinner.setMinValue(mCurrentDate.get(Calendar.DAY_OF_MONTH));
mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
mDaySpinner.setWrapSelectorWheel(false);
mMonthSpinner.setDisplayedValues(null);
mMonthSpinner.setMinValue(mCurrentDate.get(Calendar.MONTH));
mMonthSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.MONTH));
mMonthSpinner.setWrapSelectorWheel(false);
} else if (mCurrentDate.equals(mMaxDate)) {
mDaySpinner.setMinValue(mCurrentDate.getActualMinimum(Calendar.DAY_OF_MONTH));
mDaySpinner.setMaxValue(mCurrentDate.get(Calendar.DAY_OF_MONTH));
mDaySpinner.setWrapSelectorWheel(false);
mMonthSpinner.setDisplayedValues(null);
mMonthSpinner.setMinValue(mCurrentDate.getActualMinimum(Calendar.MONTH));
mMonthSpinner.setMaxValue(mCurrentDate.get(Calendar.MONTH));
mMonthSpinner.setWrapSelectorWheel(false);
} else {
mDaySpinner.setMinValue(1);
mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
mDaySpinner.setWrapSelectorWheel(true);
mMonthSpinner.setDisplayedValues(null);
mMonthSpinner.setMinValue(0);
mMonthSpinner.setMaxValue(11);
mMonthSpinner.setWrapSelectorWheel(true);
}
...代码省略...
}
看这段代码,当mCurrentDate等于mMaxDate的时候,就将当前日期的月份设置到mMonthSpinner的maxValue里面去,看上去也没啥问题啊?难道mCurrentDate和mMaxDate不相等?在去找下mCurrentDate是怎么初始化的
// initialize to current date
mCurrentDate.setTimeInMillis(System.currentTimeMillis());
在DatePickerSpinnerDelegatede的构造方法里面设置了当前时间,mMaxDate初始化的时候赋的值也是System.currentTimeMillis。一般来说这两个时间的年月日应该是相等的,莫非.equal方法判断的时候算上了时分秒?再去找一下.equal方法的逻辑。
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
try {
Calendar that = (Calendar)obj;
return compareTo(getMillisOf(that)) == 0 &&
lenient == that.lenient &&
firstDayOfWeek == that.firstDayOfWeek &&
minimalDaysInFirstWeek == that.minimalDaysInFirstWeek &&
zone.equals(that.zone);
} catch (Exception e) {
// Note: GregorianCalendar.computeTime throws
// IllegalArgumentException if the ERA value is invalid
// even it's in lenient mode.
}
return false;
}
private int compareTo(long t) {
long thisTime = getMillisOf(this);
return (thisTime > t) ? 1 : (thisTime == t) ? 0 : -1;
}
看这段代码发现,果然将两个时间戳做了比较。这两个时间戳调用System.currentTimeMillis的时机都不一样,那肯定是不可能相等的。那么也就是说你在外部获取的时间肯定不可能跟mCurrentDate一致,所以设置最大日期的时候,一定会出问题。
解决方案
既然外面设置的mMaxDate无法跟里面的mCurrentDate保持一致,那我直接反射修改里面的mCurrentDate不就可以了?说干就干于是就开始写了反射的代码:
try {
Field dataPickerSpinnerDelegateField = mBinding.dataPicker.getClass().getDeclaredField("mDelegate");
dataPickerSpinnerDelegateField.setAccessible(true);
Object dataPickerSpinnerDelegate = dataPickerSpinnerDelegateField.get(mBinding.dataPicker);
Field currentDateField = dataPickerSpinnerDelegate.getClass().getDeclaredField("mCurrentDate");
currentDateField.setAccessible(true);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
Calendar currentDate = (Calendar) currentDateField.get(dataPickerSpinnerDelegate);
currentDate.setTimeInMillis(nowTime);
} else {
java.util.Calendar currentDate = (java.util.Calendar) currentDateField.get(dataPickerSpinnerDelegate);
currentDate.setTimeInMillis(nowTime);
}
} catch (Exception e) {
e.printStackTrace();
}
因为这个mDelegate的实现类被隐藏了,所以我们在反射获取这个类的时候直接用Object就可以了。写完这段代码想想应该能成功吧?
生活总是不会跟你想象的一样,mCurrentDate无法通过反射获取(后来试了其他的板子是可以获取到的,发现似乎是Pixel的获取不到)。这下就悲催了,反射获取不到。那么我们查看下setMaxDate,看看有什么蛛丝马迹可以找到
@Override
public void setMaxDate(long maxDate) {
mTempDate.setTimeInMillis(maxDate);
if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR)
&& mTempDate.get(Calendar.DAY_OF_YEAR) == mMaxDate.get(Calendar.DAY_OF_YEAR)) {
// Same day, no-op.
return;
}
mMaxDate.setTimeInMillis(maxDate);
mCalendarView.setMaxDate(maxDate);
if (mCurrentDate.after(mMaxDate)) {
mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
updateCalendarView();
}
updateSpinners();
}
mCurrentDate设置的日期大于mMaxDate的时候mCurrentDate就会设置mMaxDate的时间,这样不就好了?而且DatePicker也提供了获取年月日的方法,想到这里就去试试
final int currYear = mBinding.dataPicker.getYear();
final int currMonth = mBinding.dataPicker.getMonth();
final int currDay = mBinding.dataPicker.getDayOfMonth();
Calendar calendar = Calendar.getInstance();
calendar.set(currYear,currMonth,currDay,0,0,0);
mBinding.dataPicker.setMinDate(DateTimeUtils.formatDateString("2000-01-01"));
mBinding.dataPicker.setMaxDate(calendar.getTimeInMillis());
通过DataPicker获取mCurrentDate的年月日,然后初始化一个Calendar,给他设置时间为0点0分0秒,在将这个Calendar作为MaxDate传进去,这样就能保证传入的MaxDate一定小于mCurrentDate。然后run一把
完美!
总结
总的来说,不是什么大问题,主要还是感觉这个DataPicker设置最大值的逻辑还是有点奇怪(不知道是不是谷歌开发者故意这么设计的)。