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

в 3:52, , рубрики: datepicker, Harmattan, MeeGo, QML, qt, qt quick, метки: , , , , ,

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


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js