Баг с кнопкой отмены в диалоге выбора даты на Android 4.x. Как исправить?

Date picker’ы в андроиде никогда не перестают удивлять. Недавно я уже писал о том, как разрешить трудности с изменением стиля диалога выбора даты на классический (Holo). В этом посте речь пойдет о другой проблеме, связанной уже не с дизайном, а с функциональностью. Вкратце: отмена диалога работает неправильно, можно сказать не работает вообще. На некоторых версиях.

Диалог выбора даты

Суть проблемы

Вот простейший пример кода для показа диалога выборы даты:

private void showDatePicker() {
    DatePickerDialog dpd = new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() {
        @Override
        public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) {
            // do something with selected date
            // ...
        }
    }, 2016, 1, 1);
    dpd.show();
}

Запустив приложение, к примеру, на Android 4.4, вы обнаружите один нюанс в поведении диалога, который большинство, я уверен, сочтет нежелательным. Заключается он в том, что нажатие пользователем кнопки «Назад» приведет к вызову коллбэка onDateSet, как если бы он нажал кнопку «ОК». К этому же результату приводит закрытие диалога кликом на форме вне его границ. Таким образом, если в этом коллбэке вы обновляете форму, к примеру, устанавливаете значение выбранной даты в текстовое поле, то это значение установится вне зависимости от того, каким образом был закрыт диалог. Это приводит к плохому UX — допустим, пользователь захотел поменять дату, открыл диалог, покрутил спиннеры, но потом передумал и нажал, к примеру, «Отмена» — текущее значение на спиннерах все равно попадет на форму. А если он уже забыл, какое значение стояло до этого? В общем — непорядок, надо исправить.

Ретроспектива

Здесь надо отметить, что данный баг присутствует на версиях Android с 4.1 по 4.4, начиная с 5.0 его уже нет; более того, на новых версиях диалог по умолчанию имеет кнопку Cancel. На Android 2.3 и 4.0 с этим диалогом тоже все в порядке, если не считать убогого pre-Holo дизайна на 2.3. Android 3.x не проверял, так как nobody cares.

Так как вышеупомянутые забагованные версии андроида по умолчанию не имеют кнопки «Отмена» в диалоге выбора даты, давайте попробуем добавить эту кнопку в порядке эксперимента:

dpd.setButton(DialogInterface.BUTTON_NEGATIVE, getString(android.R.string.cancel), dpd);
dpd.show();

Запускаем — видим, что кнопка появилась, отлично. Нажимаем на нее — дата выбралась, форма обновилась. Это неправильно. Как видите, в качестве листенера в метод setButton мы передаем сам диалог, и теоретически он должен корректно обработать кнопку, так как выставлен флаг BUTTON_NEGATIVE. Но этого не происходит, поэтому попробуем определить собственный листенер:

dpd.setButton(DialogInterface.BUTTON_NEGATIVE, getString(android.R.string.cancel),
        new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
        dialog.cancel();
    }
});

Что-то не помогло. Да и в любом случае, подобный костыль не решил бы основные проблемы — нажатие на кнопку «Назад» и клик за пределами границ диалога. Впрочем, их можно было бы решить при помощи setCancelable(true), но мы ведь не хотим этого, правда?

We need to go deeper

Кажется, пришло время обмазаться исходниками андроида. Открываем DatePickerDialog.java из ветки kitkat-release. Обнаруживаем там следующее:

@Override
protected void onStop() {
    tryNotifyDateSet();
    super.onStop();
}

private void tryNotifyDateSet() {
    if (mCallBack != null) {
        mDatePicker.clearFocus();
        mCallBack.onDateSet(mDatePicker, mDatePicker.getYear(),
                mDatePicker.getMonth(), mDatePicker.getDayOfMonth());
    }
}

Все ясно. То есть коллбэку onDateSet суждено вызваться всегда, каким бы способом диалог ни исчезал с экрана. Остается лишь поаплодировать разработчикам андроида за очередное мудрое решение. Но что делать нам? После некоторых раздумий я родил следующий костыль:

private void showDatePicker() {
    final DateSetListener listener = new DateSetListener();
    final DatePickerDialog dpd = new DatePickerDialog(this, listener, 2016, 1, 1);
    dpd.setButton(DialogInterface.BUTTON_POSITIVE, getString(R.string.date_picker_positive),
            new DialogInterface.OnClickListener() {
        @Override
        public void onClick(DialogInterface dialog, int which) {
            listener.setEnabled(true);
            dpd.onClick(dialog, which);
        }
    });
    dpd.setButton(DialogInterface.BUTTON_NEGATIVE, getString(android.R.string.cancel), dpd);
    dpd.show();
}

private class DateSetListener implements DatePickerDialog.OnDateSetListener {
    private boolean enabled = false;

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    @Override
    public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
        if (enabled) {
            // do something with selected date
            // ...
        }
    }
}

Как несложно понять, суть решения заключается в том, чтобы активировать листенер лишь после того, как была нажата кнопка подтверждения. Во всех остальных случаях коллбэк onDateSet будет просто проигнорирован.

Чтобы это решение не вызывало желание выколоть глаза себе и кодеру, который это писал, советую спрятать его подальше в какой-нибудь утилитный класс. Там же можно добавить проверку на версию API и использовать костыль лишь на забагованных версиях. Впрочем, я использую его без проверки и не замечал проблем ни с одной версией Android.

Эпилог

После прочтения моих статей о диалогах может возникнуть вопрос — зачем я так отчаянно пытаюсь заставить работать как надо диалоги, предоставляемые SDK? Ведь логика довольно проста и можно написать собственный класс диалога, используя разметку с элементом DatePicker. Ответ прост — я хочу по-максимуму абстрагироваться от реализации диалога, ведь в будущем в нем может появиться новая логика, которую сейчас предугадать невозможно. И возможно, что эта логика должна будет существовать только на новой, пока несуществующей версии андроида. В случае кастомного диалога этой логики в моем приложении не будет вообще (учитывая тенденции, это может быть и к лучшему, но давайте верить в чудо). В общем, таким образом я повышаю вероятность, что мое приложение сможет само поддерживать себя дольшее время, без необходимости вносить изменения.

Добавить комментарий

Ваш адрес email не будет опубликован.