29 октября 2013 г.

Выбор года и месяца в GWT DatePicker с помощью ListBox

Стандартный DatePicker в GWT очень неудобный. Для выбора нужного года и месяца пользователь вынужден кликать по соответствующим стрелочкам и перемещаться на один месяц вперед или на зад. Например, если пользователю необходимо выбрать свою дату рождения, скажем, 29 февраля 1980 года, а в календаре по умолчанию установлена текущая дата 25 октября 2013 года, то представьте сколько раз нужно будет кликнуть бедняге по стрелочке, чтобы добраться до дня своего рождения!
Стандартный DatePicker в GWT
Конечно, в качестве простого решения, можно просто дать пользователю возможность ввести дату самому в текстовое поле. И в общем-то нет, наверное, ничего плохого в таком решении. Но иногда просто хочется сделать немного более красиво. В этом смысле Datepicker из jQuery UI вполне себе красив и позволяет выбирать год и месяц с помощью выпадающих списков, что очень сокращает путь до необходимой даты.

jQuery Datepicker
На самом деле, такую же возможность очень несложно реализовать и в GWT DatePicker. Для этого нужно просто написать свою собственную реализацию абстрактного класса MonthSelector, который представляет из себя визуальный компонент, отвечающий за установку текущего года и месяца в DatePicker. 

Немного теории

MonthSelector расширяет класс DatePickerComponent, у которого, в свою очередь, есть метод getModel(), возвращающий экземпляр класса CalendarModel. Как вы уже поняли, экземпляр CalendarModel является моделью календаря DatePicker'а и, получив его в своё распоряжение, мы получаем возможность управлять этим календарём. Пожалуй, самое интересное в модели для нас это методы setCurrentMonth и getCurrentMonth, которые позволяют установить текущий отображаемый месяц календаря и получить его, соответственно. Это в общем-то основное знание, которое необходимо для создания собственного MonthSelector, но есть ещё одна тонкость. Установить свой собственный MonthSelector в DatePicker можно только через protected-конструктор DatePicker'а. Поэтому нужно ещё расширить класс DatePicker и в своей реализации календаря установить свой свежеприготовленный MonthSelector, воспользовавшись этим защищённым конструктором. В данном конструкторе, кроме сохранения ссылки на экземпляр MonthSelector, происходит так же вызов метода monthSelector.setDatePicker(this), т. е. в наш новый MonthSelector передаётся ссылка на использующий его экземпляр DatePicker. Именно поэтому внутри нашего MonthSelector мы имеем доступ к модели DatePicker'а.

Ещё немного теории

Как было сказано выше, всё что нужно для управления содержимым DatePicker'а, это получить доступ к его модели CalendarModel. Теперь разберёмся, что нужно для того, чтобы DatePicker смог отобразить наш MonthSelector внутри себя. А нужно для этого совсем немного – состряпать визуальный компонент, представляющий MonthSelector, из каких-либо потомков класса Widget и передать его в унаследованный от класса Composite метод initWidget(Widget widget). Например, для нашего варианта MonthSelector будет состоять из двух ListBox'ов и контейнера для них – экземпляра класса Grid.

Практика

Итак, расширяем стандартный класс DatePicker и добавляем в него новые методы для настройки поведения. Предлагаю добавить два новых метода setFloatingYearsRange и setFixedYearsRange. Первый метод будет устанавливать плавающий диапазон годов, а второй фиксированный. Плавающий диапазон будет отличаться от фиксированного тем, что при выборе нового, года список будет перестраиваться заново, таким образом, что пользователь сможет выбрать любой год, но при этом, количество годов в выпадающем списке будет постоянным. Если мысль моя изложена не слишком ясно, проще посмотреть аналогичное поведение Datepicker из jQuery UI.


public class ListBoxDatePicker extends DatePicker {
     
    /**
    * Sets the floating range of years
    * 
    * @param negativeShift - shift to the left relative to 
    *                        the selected year; must be < 0.
    * @param positiveShift - shift to the right relative to 
    *                        the selected year; must be > 0.
    */
    public void setFloatingYearsRange(int negativeShift, int positiveShift) {}
        
    /**
    * Sets the fixed range of years
    * 
    * @param first - value of the first year in the list; 
    *                must be > 0 and <= last.
    * @param last  - value of the last year in the list; 
    *                must be > 0 and >= first.
    */
    public void setFixedYearsRange(int first, int last) {}

}

В качестве параметров метод setFloatingYearsRange принимает отрицательное и положительное смещения относительно выбранного года. Если методу передать параметры -3 и 5, то при выборе 2013 года, список будет содержать года от 2010 до 2018 включительно, а при изменении года на 2010 список будет перестроен и будет содержать уже года с 2007 по 2015.

В качестве параметров метод setFixedYearsRange принимает первый и последний год списка.

С классом ListBoxDatePicker пока всё и в будущем он станет не намного сложнее, т. к. весь основной код будет в классе, который расширяет MonthSelector. Вот он с полями, которые нам понадобятся.

public class ListBoxMonthSelector extends MonthSelector {

    private static enum YearsRangeType {Fixed, Floating};
    // current type of the years range
    private YearsRangeType yearsRangeType;

    private static final DateTimeFormat monthFormat = 
                             DateTimeFormat.getFormat("yyyy-MMM");
    private static final DateTimeFormat yearFormat = 
                             DateTimeFormat.getFormat("yyyy");

    private String[] monthNames; // list of month names
    private String[] years;      // list of years available for selection

    private final ListBox yearsBox = new ListBox(false);
    private final ListBox monthsBox = new ListBox(false);

    // current shifts for floating range
    private int negativeYearShift = -1, positiveYearShift = -1;

    private Grid grid;      
}


Кратко о полях

Т. к. наличие двух методов настройки нового DatePicker'а предполагают различное поведение (фиксированный диапазон годов и плавающий), то надо иметь признак для понимания того, на какое поведение в данный момент настроен календарь. При двух вариантах можно использовать переменную boolean, но мне более по душе enum, т.к. если завтра появится новый вариант поведения, его признак будет проще добавить. Итак, enum YearsRangeType {Fixed, Floating} служит для определения поведения календаря, а поле yearsRangeType хранит текущее поведение.

Поля  класса monthFormat и yearFormat будут использоваться для форматирования значений списков годов и месяцев в строку и обратно.

Массивы строк monthNames и years, будут хранить значения месяцев и годов, на основе которых будут строиться выпадающие списки.

Экземпляры ListBox yearsBox и monthsBox это те выпадающие списки, которые будут отображаться в верхней части DatePicker'а для выбора года и месяца.

В полях negativeYearShift и positiveYearShift будут храниться текущие значения смещений для плавающего диапазона годов, а при фиксированном пускай ровняются -1.

Поле grid это именно тот компонент, который будет отображаться в качестве MonthSelector в новом DatePicker'е. Он будет содержать в себе компоненты yearsBox и monthsBox.

Основные методы

Начнём сразу с самого главного метода в нашем новом MonthSelector'е.

protected void setYearsRange(int first, int last, YearsRangeType type){
    yearsRangeType = type;
    
    switch (type) {
    
    case Fixed:
        
        this.negativeYearShift = -1;
        this.positiveYearShift = -1;
        
        buildYears(first, last);

        break;
        
    case Floating:
        // various signs for left and right shifts 
        // are needed for additional verification
        if (first >= 0) throw 
            new IllegalArgumentException("First year shift value must be < 0");
        if (last <= 0) throw 
            new IllegalArgumentException("Last year shift value must be > 0");
        
        this.negativeYearShift = Math.abs(first);
        this.positiveYearShift = last;

        int currentYear = Integer.parseInt(getCurrentModelYear());
        buildYearsByShifts(currentYear, negativeYearShift, positiveYearShift);

        break;
    }

    updateYearsBox();
    setModelByListBoxes();
}

Этот метод осуществляет настройку ListBoxMonthSelector - устанавливает диапазон доступных для выбора годов в выпадающем списке и определяет поведение этого списка (фиксированный или плавающий). Именно этот метод будет вызываться ListBoxDatePicker'ом в методах setFloatingYearsRange и setFixedYearsRange. Заметьте, что первые два параметра зависят от третьего. Т. е. для YearsRangeType.Fixed данные параметры являются первым и последним годами в списке, а для Floating левым и правым смещением относительно базового года, соответственно. Вообще говоря, это плохая затея делать такое "два в одном", но т. к. метод setYearsRange мы закрыли от клиентов модификатором protected, то можно немножко и согрешить :)

Внутри метода, в зависимости от полученного YearsRangeType, происходит генерация массива (методы buildYears и buildYearsByShifts), содержащего список годов, которые должны быть отражены в соответствующем ListBox'е. После этого, с помощью метода updateYearsBox происходит построение ListBox'а по сгенерированному массиву и установка значения модели по выбранным значениям в ListBox'ах (метод setModelByListBoxes).

Метод setModelByListBoxes очень прост. Берём значения года и месяца из ListBox'ов и с помощью DateTimeFormat создаём экземпляр Date, который используем для установки текущего отображаемого месяца нашего календаря. После изменения модели необходимо вызвать метод refreshAll, чтобы DatePicker перерисовал календарь в соответствии с новым значением модели.

private void setModelByListBoxes() {
    String year = yearsBox.getItemText(yearsBox.getSelectedIndex());
    String month = monthsBox.getItemText(monthsBox.getSelectedIndex());

    getModel().setCurrentMonth(
            monthFormat.parse(year + "-" + month));
    refreshAll();
}

Если пользователь выберет новое значение в каком-то из наших списков, то нужно будет просто вызвать этот метод для изменения текущего отображаемого месяца DatePicker'а. Но, кроме пользователя, значение текущего месяца календаря может изменить класс-клиент, т. к. из экземпляра DatePicker'а доступен публичный метод setCurrentMonth, который вызывает одноименный метод модели и обновляет все свои компоненты, в том числе и MonthSelectror. Поэтому в дополнение к методу установки текущего месяца по значениям выпадающих списков, нам также нужен обратный метод, который выбирает соответствующие значения в ListBox'ах, если значение текущего месяца модели календаря изменилось программно.

private void setListBoxesByModel() {

    String currentMonth = monthFormat.
                            format(getModel().getCurrentMonth());
    
    String[] yearAndMonth = currentMonth.split("-");

    boolean yearSetted = false;
    boolean monthSetted = false;
    
    for (int i = 0; i < yearsBox.getItemCount(); i++) {
        if (yearsBox.getItemText(i).equals(yearAndMonth[0])) {
            yearsBox.setSelectedIndex(i);
            yearSetted = true;
            break;
        }
    }

    for (int i = 0; i < monthsBox.getItemCount(); i++) {
        if (monthsBox.getItemText(i).equalsIgnoreCase(yearAndMonth[1])) {
            monthsBox.setSelectedIndex(i);
            monthSetted = true;
            break;
        }
    }

    if(!yearSetted || !monthSetted)
        throw new RuntimeException("Can't set month " + currentMonth + 
                                   " in " + getClass().getName());
}

Для сравнения названий месяцев используется equalsIgnoreCase, т. к. я заметил, что для русской локали DateTimeFormat возвращает короткие названия месяцев (формат "MMM") с маленькой буквы, а названия в массиве monthNames и, соответственно, в списке monthsBox начинаются с заглавной буквы. Такими их возвращает метод monthsShortStandalone из интерфейса DateTimeFormatInfo, который мы будем использовать ниже для инициализации списка месяцев. В детали этого метода я пока не вникал.

В конце метода происходит проверка удалось ли установить месяц и год в списках, которые соответствуют значению месяца модели. Если нет, бросается RuntimeException. Такая ситуация может произойти, например, если в модель устанавливается значение года, которое не существует в выпадающем списке. Это место надо по-хорошему доработать и в целом предусмотреть другое поведение для таких случаев.

Как я уже писал выше, клиенты могут изменить месяц календаря через публичный метод DatePicker'а. В этом методе после изменения модели происходит обновление всех компонентов DatePicker'а. Поэтому, для того что бы установить соответствующие значения в списках при изменении модели, нам надо переопределить метод refresh нашего ListBoxMonthSelector и установить в нем новые значения в ListBox'ах. Именно этот метод будет вызываться DatePicker'ом после обновления модели.

@Override
protected void refresh() {
    setListBoxesByModel();
}

Итак, я описал основные рабочие методы из которых должно стать ясно, как работать с моделю календаря из MonthSelector. Остальные служебные методы вроде buildYears и buildYearsByShifts я описывать не буду, а просто приведу в конце статьи полный код с комментариями на моём корявом индокитайском диалекте английского языка :) Код хранится на гитхабе, поэтому я стараюсь там держать околоанглийские комментарии, вдруг кому пригодится.

Разберемся теперь как и самое главное когда инициализировать компоненты нашего нового MonthSelector. Для удобства восприятия разделим инициализацию на три этапа: инициализация списка годов, инициализация списка месяцев и инициализация всего компонента в целом.

Инициализация списка годов

Заставляем выпадающий список годов показывать только один выбранный элемент и навешиваем на него обработчик события, которое будет происходить при выборе пользователем какого-либо года в списке. При срабатывании события, если список плавающий, то надо перестроить весь список годов, а если нет, то список годов строится единожды при вызове метода setYearsRange. По крайней мере пока оставим поведение именно таким. После перестроения изменяем модель календаря в соответствии установленным значением.

private void initYearsBox() {
    yearsBox.setVisibleItemCount(1);
    yearsBox.addChangeHandler(new ChangeHandler() {
        @Override
        public void onChange(ChangeEvent event) {
            if(yearsRangeType == YearsRangeType.Floating) {
                buildYearsByShifts(Integer.parseInt(getSelectedYear()), 
                                   negativeYearShift, positiveYearShift);
                updateYearsBox();
            }
            setModelByListBoxes();
        }
    });
}

Инициализация списка месяцев

Сначала получаем локализованные короткие названия месяцев. Кстати, для русской локали названия слегка странные, на мой взгляд. Например, "Февр." или "Нояб."  вместо привычных "Фев" и "Ноя" без точки. После заполнения массива названий месяцев, так же как и в предыдущем методе, устанавливаем один отображаемый элемент для списка месяцев и создаем обработчик для события изменения выбранного месяца и в нём только обновляем модель календаря.

private void initMonthsBox() {
    // localized short month names
    monthNames = LocaleInfo.getCurrentLocale().
                    getDateTimeFormatInfo().monthsShortStandalone();
    for (String month : monthNames) {
        monthsBox.addItem(month);
    }
    
    monthsBox.setVisibleItemCount(1);
    monthsBox.addChangeHandler(new ChangeHandler() {
        @Override
        public void onChange(ChangeEvent event) {
            setModelByListBoxes();
        }
    });
}

Инициализация MonthSelector

Как уже, отмечалось, основой для визуальной части MonthSelector у нас будет компонент Grid. Создаём его размером 1 на 2 и помещаем в него компоненты список годов и список месяцев. Добавляем на всякий случай уникальное имя класса стиля и передаём созданный компонент в метод initWidget. После выполнения initWidget, DatePicker сможет отобразить внутри себя наш новый MonthSelector.


private void initMonthSelector(){
    grid = new Grid(1, 2);
    grid.setWidget(0, 0, yearsBox);
    grid.setWidget(0, 1, monthsBox);
    grid.setStyleName("ListBoxMonthSelector");

    initWidget(grid);
}

Необходимые методы инициализации созданы. Когда же их теперь вызвать? В принципе, в нашем случае эти три метода можно вызвать в конструкторе ListBoxMonthSelector, но лучше всего воспользоваться переопределённым методом setup, который будет автоматически вызван при создании экземпляра DatePicker. Преимущество использования данного метода для инициализации компонента перед конструктором заключается в том, что в момент выполнения setup, вам уже доступен экземпляр DatePicker'а и, соответственно, его модель, которые могут пригодиться для инициализации. Кроме инициализации компонентов, нам ещё нужно определить настройки по умолчанию для ListBoxMonthSelector. Сделаем это с помощью метода setYearsRange. И вот этот метод уже не может быть использован в конструкторе, т.к. внутри него происходит обращение к модели календаря, которая в конструкторе нам ещё не доступна.

@Override
protected void setup() {
    initYearsBox();
    initMonthsBox();
    initMonthSelector(); 
    setYearsRange(-7, 7, YearsRangeType.Floating);
}

По большому счёту, все необходимые шаги для создания собственного MonthSelector я описал. Теперь приведу полный код ListBoxMonthSelector.



И теперь полный код ListBoxDatePicker.



Обратите внимание, что каждый метод установки диапазона годов в ListBoxDatePicker заканчивается вызовом метода refresh для DefaultCalendarView. Этим мы сигнализируем, что модель календаря изменилась и заставляем обновить его представление.

Пример использования ListBoxDatePicker:

// Fixed range of years
ListBoxDatePicker datePicker1 = new ListBoxDatePicker();
datePicker1.setFixedYearsRange(2005, 2015);

// Floating range of years
ListBoxDatePicker datePicker2 = new ListBoxDatePicker();
datePicker2.setFloatingYearsRange(-5, 5);

А вот что получилось


Созданный календарь является просто примером и не претендует на большую стабильность, поэтому без тщательной проверки и доработки пользоваться им для публичных проектов не рекомендую :)
  
Исходный код с примером доступен тут. Там возможно, он будет обновляться и совершенствоваться, если мне это будет нужно.

Комментариев нет:

Отправить комментарий