DatePicker最大日期显示问题

背景

前段时间公司测试给我提了一个bug:在日期选择框弹出来的时候,显示出了未来1个月的日期,如下所示:


Screenshot_20200717-161109.png

需求是说用户无法选择今天以后的日期,所以要将未来的日期给隐藏掉。


探索

所以,我立刻去查看了下自己的代码:

        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]);
        }
    }

搜了一圈发现只在这个方法里面被赋值过,然后在查看下这个方法的调用地方


image.png

找到了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值到底怎么传的。


image.png

因为我们现在设置的是日期,那么必然就看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就可以了。写完这段代码想想应该能成功吧?


image.png

生活总是不会跟你想象的一样,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一把


image.png

完美!


总结

总的来说,不是什么大问题,主要还是感觉这个DataPicker设置最大值的逻辑还是有点奇怪(不知道是不是谷歌开发者故意这么设计的)。

你可能感兴趣的:(DatePicker最大日期显示问题)