Pull to refresh

Создаем DatePicker аналогичный стандартному в Harmattan

Qt *
Tutorial
Некоторое время назад в рамках конкурса на лучшую статью о Qt, я разработал компонент TimePicker и написал о нем статью. Мало того, в комментариях, я говорил о том, что следующим компонентом будет DatePicker. Несколько дней назад я закончил его.

Для тех кто не читал предыдущую статью поясняю: не все компоненты используемые Nokia в своих приложениях на Harmattan свободны, некоторые из них не включены в Qt Components для MeeGo, а некоторые заменены суррогатами, картинка слева — оригинал, картинка справа — предлагаемый разработчикам суррогат компонента DatePicker.

Требования


Первое что необходимо, это определить требования к компоненту, поскольку мне я собирался реализовать аналог уже существующего компонента, это просто.
Итак, DatePicker должен выглядеть как календарь на указанный месяц, и захватывать крайние дни предыдущего и последующего месяца. Навигация между месяцами осуществляется по нажатию на стрелки, по жесту сдвига и при тапе на число предыдущего/последующего месяца. Текущий выбранный день должен выделятся рамкой и переключаться по тапу. Сегодняшний день также должен выделяться особой рамкой.
Вторым требованием я определил, что компонент должен быть реализован независимо от MeeGo Qt Components, что обеспечит его переносимость на любые платформы поддерживающие Qt Quick.

Компонент


Лирическое отступление

Изначально следует заложить гибкую настройку внешнего вида компонента. А поскольку он достаточно сложен, то и настроек у него будет относительно много. Я пошел тем же путем что и авторы оригинальных Qt Components, а именно создал простой компонент стиля, который заполнен свойствами с настройкой по-умолчанию.

QtObject {
    id: style

    property string orientationString: "portrait"
    property string backgroundImage: "image://theme/meegotouch-calendar-monthgrid-background-" + orientationString
    property string currentDayImage: "image://theme/meegotouch-monthgrid-daycell-current-day-" + orientationString
    property string selectedDayImage: "image://theme/meegotouch-monthgrid-daycell-selected-day-" + orientationString
    property string currentSelectedDayImage: "image://theme/meegotouch-monthgrid-daycell-selected-day-current-" + orientationString

    property string leftArrowImage: "image://theme/meegotouch-calendar-monthgrid-previousbutton"
    property string leftArrowPressedImage: "image://theme/meegotouch-calendar-monthgrid-previousbutton-pressed"
    property string rightArrowImage: "image://theme/meegotouch-calendar-monthgrid-nextbutton"
    property string rightArrowPressedImage: "image://theme/meegotouch-calendar-monthgrid-nextbutton-pressed"

    property string eventImage: "image://theme/meegotouch-monthgrid-daycell-regular-day-eventindicator"
    property string weekEndEventImage: "image://theme/meegotouch-monthgrid-daycell-regular-weekend-day-eventindicator"
    property string currentDayEventImage: "image://theme/meegotouch-monthgrid-daycell-current-day-eventindicator"
    property string selectedDayEventImage: "image://theme/meegotouch-monthgrid-daycell-selected-day-eventindicator"
    property string otherMonthEventImage: "image://theme/meegotouch-monthgrid-daycell-othermonth-day-eventindicator"

    property color weekEndColor: "#EF5500"
    property color weekDayColor: "#8C8C8C"
    property color otherMonthDayColor: "#8C8C8C"
    property color dayColor: "#000000"
    property color monthColor: "#000000"
    property color currentDayColor: "#EF5500"
    property color selectedDayColor: "#FFFFFF"

    property int monthFontSize: 32
    property int dayNameFontSize: 18
    property int dayFontSize: 26
}

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

О главном

Первым делом надо определить структуру нашего компонента, в общем виде она состоит из трех элементов:
  • Шапка с названием месяца и стрелками
  • Строка с днями недели
  • Решетка с числами месяца

Рассмотрим их по отдельности.

Шапка с названием месяца и стрелками

Item {
                id: header

                anchors {
                    left: parent.left
                    right: parent.right
                    top: parent.top
                }

                height: 65

                Item {
                    id: leftArrow

                    anchors {
                        left: parent.left
                        top: parent.top
                        bottom: parent.bottom
                    }

                    width: 100
                    height: 65

                    Image {
                        id: leftArrowImage

                        anchors {
                            left: parent.left
                            leftMargin: (header.width / 7) / 2 - (width / 2)
                            verticalCenter: parent.verticalCenter
                        }

                        width: height
                        source: root.platformStyle.leftArrowImage
                    }

                    MouseArea {
                        anchors.fill: parent

                        onPressed: {
                            leftArrowImage.source = root.platformStyle.leftArrowPressedImage
                        }

                        onReleased: {
                            leftArrowImage.source = root.platformStyle.leftArrowImage
                            previousMonthAnimation.start()
                            dateModel.showPrevious()
                        }
                    }
                }

                Text {
                    id: monthLabel
                    anchors.centerIn: parent
                    font.pixelSize: root.platformStyle.monthFontSize
                    font.weight: Font.Light
                    color: root.platformStyle.monthColor
                }

                Item {
                    id: rightArrow

                    anchors {
                        right: parent.right
                        top: parent.top
                        bottom: parent.bottom
                    }

                    width: 100
                    height: 70

                    Image {
                        id: rightArrowImage

                        anchors {
                            right: parent.right
                            rightMargin: (header.width / 7) / 2 - (width / 2)
                            verticalCenter: parent.verticalCenter
                        }

                        width: height
                        source: root.platformStyle.rightArrowImage
                    }

                    MouseArea {
                        anchors.fill: parent
                        onPressed: {
                            rightArrowImage.source = root.platformStyle.rightArrowPressedImage
                        }

                        onReleased: {
                            rightArrowImage.source = root.platformStyle.rightArrowImage
                            nextMonthAnimation.start()
                            dateModel.showNext()
                        }
                    }
                }
            }

Здесь все достаточно просто и понятно с первого взгляда: по середине текстовое поле, а по краям две картинки со стрелочками, которые меняются при нажатии, и запускают процедуры смены месяца, а также анимацию.

Строка с днями недели

Row {
                id: weekDaysGrid

                anchors {
                    left: parent.left
                    right: parent.right
                    top: header.bottom
                    bottomMargin: 10
                }

                width: parent.width

                WeekCell {
                    text: qsTr("Mon")
                    platformStyle: datePicker.platformStyle
                }
                WeekCell {
                    text: qsTr("Tue")
                    platformStyle: datePicker.platformStyle
                }
                WeekCell {
                    text: qsTr("Wed")
                    platformStyle: datePicker.platformStyle
                }
                WeekCell {
                    text: qsTr("Thu")
                    platformStyle: datePicker.platformStyle
                }
                WeekCell {
                    text: qsTr("Fri")
                    platformStyle: datePicker.platformStyle
                }
                WeekCell {
                    isWeekEnd: true
                    text: qsTr("Sat")
                    platformStyle: datePicker.platformStyle
                }
                WeekCell {
                    isWeekEnd: true
                    text: qsTr("Sun")
                    platformStyle: datePicker.platformStyle
                }
            }

Компонент Row выстраивает своих детей в одну строку, в том порядке, в котором они объявлены.
Как видно из кода, в качестве детей используются собственные компоненты, которые, однако, достаточно просты:
Item {
    id: weekCell
    property alias text: label.text
    property QtObject platformStyle: DatePickerStyle {}
    property bool isWeekEnd: false

    height: label.height
    width: parent.width / 7

    Text {
        id: label
        anchors.centerIn: parent
        font.pixelSize: weekCell.platformStyle.dayNameFontSize
        color: weekCell.isWeekEnd ? weekCell.platformStyle.weekEndColor : weekCell.platformStyle.weekDayColor
        font.bold: true
    }
}

Особенно следует обратить внимание на передачу объекта стиля, в эти компоненты от DatePicker, таким образом мы избавляем пользователя от дополнительных забот — по сути ему вообще не обязательно знать о существовании компонента WeekCell.

Решетка с числами месяца

Для решетки с числами я использовал GridView:
GridView {
                id: daysGrid

                anchors {
                    top: weekDaysGrid.bottom
                    left: parent.left
                    right: parent.right
                    bottom: parent.bottom
                }
                cellWidth: width / 7 - 1
                cellHeight: height / 6

                interactive: false

                delegate: DayCell {
                    platformStyle: datePicker.platformStyle

                    width: daysGrid.cellWidth;
                    height: daysGrid.cellHeight

                    isCurrentDay: model.isCurrentDay
                    isOtherMonthDay: model.isOtherMonthDay
                    hasEventDay: model.hasEventDay

                    dateOfDay: model.dateOfDay
                }

                model: DateModel {
                    id: dateModel
                    currentDate: new Date()

                    onMonthChanged: {
                        monthLabel.text = getMonthYearString()
                        daysGrid.currentIndex = dateModel.firstDayOffset + selectedDate.getDate() - 1
                    }

                    onSelectedDateChanged: {
                        root.selectedDateChanged(selectedDate)
                    }
                }

                MouseArea {
                    anchors.fill: parent

                    property int pressedPosition: 0

                    onPressed: {
                        pressedPosition = mouseX
                    }

                    onReleased: {
                        var delta =  mouseX - pressedPosition;
                        if (Math.abs(delta) > 100) {
                            if (delta < 0) {
                                nextMonthAnimation.start()
                                dateModel.showNext()
                            }
                            else {
                                previousMonthAnimation.start()
                                dateModel.showPrevious()
                            }
                        }
                        pressedPosition = 0

                        if (Math.abs(delta) < 20) {
                            var index = daysGrid.indexAt(mouseX, mouseY)
                            daysGrid.currentIndex = index
                            dateModel.selectedDate = daysGrid.currentItem.dateOfDay
                            if (daysGrid.currentItem.isOtherMonthDay) {
                                if (daysGrid.currentItem.dateOfDay.getMonth() < dateModel.selectedDate.getMonth())
                                    previousMonthAnimation.start()
                                else
                                    nextMonthAnimation.start()

                                dateModel.changeModel(daysGrid.currentItem.dateOfDay)
                            }
                        }
                    }
                }
            }

Здесь следует особо обратить внимание на то, что у View отключен интерактивный режим и его полностью закрывает единственная MouseArea, это решает проблему с жестами сдвига, мы просто обрабатываем длину пройденного пальцем пути, и если она превышает заданное число осуществляем переход на новый месяц. Если же путь совсем не велик, значит пользователь просто нажал на определенный день. Определить позицию необходимой ячейки нам позволяет замечательный метод indexAt, который по пиксельным координатам возвращает индекс ячейки.

Сам делегат ячейки очень прост:
Item {
    id: dayCell
    property QtObject platformStyle: DatePickerStyle {}
    property bool isOtherMonthDay: false
    property bool isCurrentDay: false
    property bool isSelectedDay: false
    property bool hasEventDay: false

    property date dateOfDay

    function color() {
        if (GridView.isCurrentItem)
            return platformStyle.selectedDayColor
        else if (isCurrentDay)
            return platformStyle.currentDayColor
        else if (isOtherMonthDay)
            return platformStyle.otherMonthDayColor
        return platformStyle.dayColor
    }

    function background() {
        if (GridView.isCurrentItem) {
            if (isCurrentDay)
                return platformStyle.currentSelectedDayImage
            return platformStyle.selectedDayImage
        }
        else if (isCurrentDay)
            return platformStyle.currentDayImage
        return ""
    }

    function eventImage() {
        if (GridView.isCurrentItem)
            return platformStyle.selectedDayEventImage
        else if (dateOfDay.getDay() === 0 || dateOfDay.getDay() === 6)
            return platformStyle.weekEndEventImage
        else if (isCurrentDay)
            return platformStyle.currentDayEventImage
        else if (isOtherMonthDay)
            return platformStyle.otherMonthEventImage
        return platformStyle.eventImage
    }

    Image {
        id: background
        anchors.centerIn: parent

        source: dayCell.background()
        Text {
            id: label
            anchors.centerIn: parent
            font.pixelSize: dayCell.platformStyle.dayFontSize
            color: dayCell.color()
            font.weight: (dayCell.isCurrentDay || dayCell.GridView.isCurrentItem) ? Font.Bold : Font.Light
            text: dayCell.dateOfDay.getDate()
        }
        Image {
            anchors {
                top: label.bottom
                topMargin: -5
                horizontalCenter: parent.horizontalCenter
            }

            visible: hasEventDay
            source: dayCell.eventImage()
        }
    }
}

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

Модель


Самая сложная, муторная и неоднозначная часть компонента. Дело в том что мне хотелось сделать модель исключительно на Qml/ECMAScript, хотя решение на С++ было бы и красивее и проще, однако это создало бы дополнительные сложности с внедрением, ибо пользователь был бы вынужден таскать за собой еще и qml плагин с C++ кодом.
Так вот, сложность заключается в том, что в ECMAScript отвратительный встроенный класс для работы с датой, он ужасен, он почти ничего не умеет, например он не может сказать високосный ли год по текущей дате, или сколько в текущем месяце дней. Или, например, на какой день недели приходится первое число текущего месяца. Все это пришлось создавать самому.

Вспомогательные части модели

Я не являюсь ни гуру ни поклонником ECMAScript/Javascript, поэтому абсолютно не уверен в том что эти методы сделаны максимально оптимально. Они выполняют свою функцию, но на мой взгляд они уродливы.
Я не буду приводить здесь их реализацию, приведу лишь названия.
function isLeapYear(year);
function getValidDayByMonthAndDay(month, day, leapYear);

Первый возвращает истину, если год високосный, а второй возвращает правильное число в месяце по предполагаемому числу, месяцу и «високосности» года, проще говоря, она корректирует граничные значения при переходе между месяцами.

Интерфейсные методы модели

//public:
    function setEvent(eventDate, enable) {
        if (eventDate.getMonth() !== selectedDate.getMonth() && eventDate.getFullYear() !== selectedDate.getFullYear())
            return
        setProperty(eventDate.getDate() + firstDayOffset, "hasEventDay", enable)
    }

    function getMonthYearString() {
        return Qt.formatDate(selectedDate, "MMMM yyyy")
    }

    function showNext() {
        showOtherMonth(selectedDate.getMonth() + 1)
    }

    function showPrevious() {
        showOtherMonth(selectedDate.getMonth() - 1)
    }

    function changeModel(_selectedDate) {
        clear()
        selectedDate = _selectedDate

        fillModel()
        monthChanged()
    }

Метод setEvent устанавливает или сбрасывает флаг события на заданную дату, как видно из кода, сейчас обрабатываются события только текущего месяца, что заставляет пользователей использующих события отслеживать изменения даты и устанавливать события при каждой смене заново. В дальнейшем я планирую решить этот вопрос путем, создания массива событий внутри модели.
Метод getMonthYearString просто возвращает сроку в формате «месяц год», как нетрудно догадаться, это нужно для заголовка DatePicker'а.
Методы showNext и showPrevious просто переключают модель на следующий или предыдущий месяц соответственно.
Ну а метод changeModel я позволяет менять текущую выбранную дату на произвольную.

Приватные методы модели

К сожалению Qml пока не умеет делать методы приватными, но ниж будут представлены методы, которые ДОЛЖНЫ быть приватными.

    function showOtherMonth(month) {
        var newDate = selectedDate
        var currentDay = selectedDate.getDate()
        currentDay = getValidDayByMonthAndDay(month, currentDay, isLeapYear(selectedDate.getFullYear()));
        newDate.setMonth(month, currentDay)
        changeModel(newDate)
    }

   function fillModel() {
        var tmpDate = selectedDate
        tmpDate.setDate(selectedDate.getDate() - (selectedDate.getDate() - 1))
        var firstDayWeekDay = tmpDate.getDay()
        if (firstDayWeekDay === 0)
            firstDayWeekDay = 6
        else
            firstDayWeekDay--
        firstDayOffset = firstDayWeekDay

        for(var i = 0; i < 6 * 7; ++i) {
            var objectDate = selectedDate;
            objectDate.setDate(selectedDate.getDate() - (selectedDate.getDate() - 1 + firstDayOffset - i))
            appendDayObject(objectDate)
        }
    }

    function appendDayObject(dateOfDay) {

        append({
                   "dateOfDay" : dateOfDay,
                   "isCurrentDay" : dateOfDay.getDate() === currentDate.getDate() &&
                                    dateOfDay.getMonth() === currentDate.getMonth() &&
                                    dateOfDay.getFullYear() === currentDate.getFullYear(),
                   "isOtherMonthDay" : dateOfDay.getMonth() !== selectedDate.getMonth(),
                   "hasEventDay" : false
               })
    }

Метод showOtherMonth передвигает модель на другой месяц, оставляя число неизменным(с учетом корректировки границ, конечно же).
Метод fillModel Заполняет модель числами месяца, для этого он сначала выясняет с какого дня недели начинается месяц.
Метод appendDayObject просто добавляет в модель новую запись по указанному шаблону.

Заключение


На этом все. Комментарии, пожелания, предложения, багрепорты, патчи — милости прошу.

Код компонента доступен на Gitorius. Распространяется, как и TimePicker, под BSD License. Краткое руководство по использованию можно найти в моем блоге.
Tags: QtQMLQt QuickMeeGoHarmattanN9native lookdatepicker
Hubs: Qt
Total votes 35: ↑33 and ↓2 +31
Comments 3
Comments Comments 3

Popular right now

Top of the last 24 hours