
Некоторое время назад в рамках конкурса на лучшую статью о 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. Краткое руководство по использованию можно найти в моем блоге.
