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设置最大值的逻辑还是有点奇怪(不知道是不是谷歌开发者故意这么设计的)。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345