Продвинутые методы использования архитектуры модель-представление-делегат

Добавлено 12 апреля 2022 в 12:54

PathView

Элемент PathView является наиболее гибким представлением в Qt Quick, но также и самым сложным. Он позволяет создать представление, в котором элементы располагаются по произвольному пути. По тому же пути можно детально контролировать такие атрибуты, как масштаб, непрозрачность и многое другое.

При использовании PathView вы должны определить делегата и путь. В дополнение к этому сам PathView можно настроить с помощью ряда свойств. Наиболее обычными являются pathItemCount, контролирующее количество элементов, видимых одновременно, и свойства управления диапазоном выделения, preferrdHighlightBegin, preferredHighlightEnd и highlightRangeMode, контролирующие, где на пути должен отображаться текущий элемент.

Прежде чем подробно рассмотреть свойства управления диапазоном выделения, мы должны рассмотреть свойство пути. Свойство path ожидает элемент Path, определяющий путь, по которому следуют делегаты при прокрутке PathView. Путь определяется с помощью свойств startX и startY в сочетании с такими элементами пути, как PathLine, PathQuad и PathCubic. Эти элементы соединяются вместе, образуя двухмерный путь.

Когда путь определен, его можно дополнительно настроить с помощью элементов PathPercent и PathAttribute. Они размещаются между элементами пути и обеспечивают более точное управление путем и делегатами на нем. PathPercent определяет, насколько большая часть пути проходит между каждым элементом. Это, в свою очередь, контролирует распределение делегатов по пути, так как они распределяются пропорционально проценту прохождения.

Именно здесь вступают в игру свойства preferredHighlightBegin и preferredHighlightEnd объекта PathView. Оба они ожидают реальных значений в диапазоне от нуля до единицы. Также ожидается, что конец будет больше или равен началу. Установив оба этих свойства, например, 0.5, текущий элемент будет отображаться на пятидесяти процентах пути.

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

Пример PathView

В приведенном ниже примере показано, как элемент PathView используется для создания представления карточек, которые пользователь может пролистывать. Для этого используется ряд приемов. Путь состоит из трех элементов PathLine. Используя элементы PathPercent, центральный элемент правильно центрируется и обеспечивает достаточно места, чтобы не загромождаться другими элементами. С помощью элементов PathAttribute можно управлять поворотом, размером и z-значением.

В дополнение к path было задано свойство pathItemCount объекта PathView. Оно определяет, насколько плотно будет заполнен путь. preferredHighlightBegin и preferredHighlightEnd, а также PathView.onPath используются для управления видимостью делегатов.

PathView {
    anchors.fill: parent

    model: 100
    delegate: flipCardDelegate

    path: Path {
        startX: root.width / 2
        startY: 0

        PathAttribute { name: "itemZ"; value: 0 }
        PathAttribute { name: "itemAngle"; value: -90.0; }
        PathAttribute { name: "itemScale"; value: 0.5; }
        PathLine { x: root.width / 2; y: root.height * 0.4; }
        PathPercent { value: 0.48; }
        PathLine { x: root.width / 2; y: root.height * 0.5; }
        PathAttribute { name: "itemAngle"; value: 0.0; }
        PathAttribute { name: "itemScale"; value: 1.0; }
        PathAttribute { name: "itemZ"; value: 100 }
        PathLine { x: root.width / 2; y: root.height * 0.6; }
        PathPercent { value: 0.52; }
        PathLine { x: root.width / 2; y: root.height; }
        PathAttribute { name: "itemAngle"; value: 90.0; }
        PathAttribute { name: "itemScale"; value: 0.5; }
        PathAttribute { name: "itemZ"; value: 0 }
    }

    pathItemCount: 16

    preferredHighlightBegin: 0.5
    preferredHighlightEnd: 0.5
}

Делегат, показанный ниже, использует прикрепленные свойства itemZ, itemAngle и itemScale из элементов PathAttribute. Стоит отметить, что из wrapper доступны только прикрепленные свойства делегата. Таким образом, свойство rotX определено так, чтобы можно было получить доступ к значению из элемента Rotation.

Еще одна деталь, характерная для PathView, на которую стоит обратить внимание, – это использование прикрепленного свойства PathView.onPath. Общепринятой практикой является привязка к нему видимости, так как это позволяет PathView сохранять невидимые элементы для кэширования. Обычно это невозможно обработать путем отсечения, так как делегаты элементов в PathView размещаются более свободно, чем делегаты элементов в представлениях ListView или GridView.

Component {
    id: flipCardDelegate

    BlueBox {
        id: wrapper

        required property int index
        property real rotX: PathView.itemAngle

        visible: PathView.onPath

        width: 64
        height: 64
        scale: PathView.itemScale
        z: PathView.itemZ

        antialiasing: true

        gradient: Gradient {
            GradientStop { position: 0.0; color: "#2ed5fa" }
            GradientStop { position: 1.0; color: "#2467ec" }
        }

        transform: Rotation {
            axis { x: 1; y: 0; z: 0 }
            angle: wrapper.rotX
            origin { x: 32; y: 32; }
        }

        text: wrapper.index
    }
}

При преобразовании изображений или других сложных элементов в PathView обычно используется прием оптимизации производительности, заключающийся в привязке свойства smooth элемента Image к присоединенному свойству PathView.view.moving. Это означает, что изображения менее красивы в движении, но плавно трансформируются в неподвижном состоянии. Нет смысла тратить вычислительную мощность на плавное масштабирование, когда представление находится в движении, так как пользователь всё равно этого не увидит.

Подсказка

Учитывая динамическую природу PathAttribute, инструментарий qml (в данном случае: qmllint) не знает ни itemZ, ни itemAngle, ни itemScale.

При использовании PathView и программном изменении currentIndex может потребоваться управлять направлением движения пути. Это можно сделать с помощью свойства movementDirection. Для него можно установить значение PathView.Shortest, которое является значением по умолчанию. Это означает, что движение может быть в любом направлении, в зависимости от того, какой путь ближе всего к целевому значению. Направление также можно ограничить, установив для movementDirection значение PathView.Negative или PathView.Positive.

Табличные модели

Все представления, обсуждавшиеся до сих пор, так или иначе, представляют собой массив элементов. Даже GridView ожидает, что модель предоставит одномерный список элементов. Для двумерных таблиц данных вам необходимо использовать элемент TableView.

TableView похож на другие представления тем, что он объединяет модель с делегатом для формирования сетки. Если задана модель, ориентированная на список, то представление отображает один столбец, что делает его очень похожим на элемент ListView. Однако TableView также может отображать двумерные модели, которые явно определяют как столбцы, так и строки.

В приведенном ниже примере мы настроили простой TableView с пользовательской моделью, предоставленной из C++. На данный момент невозможно создавать табличные модели непосредственно из QML, но в главе «Qt и C++» объясняется эта концепция. Рабочий пример показан на изображении ниже.

Пример TableView

В приведенном ниже примере мы создаем TableView и устанавливаем rowSpacing и columnSpacing для управления горизонтальными и вертикальными промежутками между делегатами. Остальные свойства настраиваются так же, как и для любого другого типа представления.

TableView {
    id: view
    anchors.fill: parent
    anchors.margins: 20

    rowSpacing: 5
    columnSpacing: 5

    clip: true

    model: tableModel
    delegate: cellDelegate
}

Сам делегат может передавать неявный размер через implicitWidth и implicitHeight. Это то, что мы делаем в примере ниже. Фактическое содержимое данных – данные, возвращенные ролью display модели.

Component {
    id: cellDelegate

    GreenBox {
        id: wrapper

        required property string display

        implicitHeight: 40
        implicitWidth: 40

        Text {
            anchors.centerIn: parent
            text: wrapper.display
        }
    }
}

В зависимости от содержимого модели можно предоставить делегаты разных размеров, например:

GreenBox {
    implicitHeight: (1 + row) * 10
    // ...
}

Обратите внимание, что ширина и высота должны быть больше нуля.

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

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

TableView {
    columnWidthProvider: function (column) { return 10 * (column + 1) }
    // ...
}

Если вам нужно динамически изменить ширину столбцов или высоту строк, вы должны уведомить представление об этом, вызвав метод forceLayout. Это заставит представление пересчитать размер и положение всех ячеек.

Модель из XML

Поскольку XML является универсальным форматом данных, QML предоставляет элемент XmlListModel, который предоставляет XML-данные в качестве модели. Этот элемент может извлекать данные XML локально или удаленно, а затем обрабатывать их с помощью выражений XPath.

Пример ниже демонстрирует получение изображений из потока RSS. Свойство source ссылается на удаленное расположение по протоколу HTTP, и данные загружаются автоматически.

Пример XmlListModel

Когда данные загружены, они обрабатываются в элементы модели и роли. Свойство query элемента XmlListModel – это XPath, представляющий базовый запрос для создания элементов модели. В этом примере путь – /rss/channel/item, поэтому для каждого тега элемента, внутри тега канала, внутри тега RSS создается элемент модели.

Для каждого элемента модели извлекается ряд ролей. Они представлены элементами XmlListModelRole. Каждой роли дается имя, к которому делегат может получить доступ через присоединенное свойство. Фактическое значение каждого такого свойства определяется через свойства elementName и (необязательное) attributeName для каждой роли. Например, свойство title соответствует XML-элементу title, возвращая содержимое между тегами <title> и </title>.

Свойство imageSource извлекает значение атрибута тега вместо содержимого тега. В этом случае атрибут url тега enclosure извлекается как строка. Затем свойство imageSource можно использовать непосредственно в качестве источника для элемента Image, который загружает изображение с заданного URL-адреса.

import QtQuick
import QtQml.XmlListModel
import "../common"

Background {
    width: 300
    height: 480

    Component {
        id: imageDelegate

        Box {
            id: wrapper

            required property string title 
            required property string imageSource

            width: listView.width
            height: 220
            color: '#333'

            Column {
                Text {
                    text: wrapper.title
                    color: '#e0e0e0'
                }
                Image {
                    width: listView.width
                    height: 200
                    fillMode: Image.PreserveAspectCrop
                    source: wrapper.imageSource
                }
            }
        }
    }

    XmlListModel {
        id: imageModel

        source: "https://www.nasa.gov/rss/dyn/image_of_the_day.rss"
        query: "/rss/channel/item"

        XmlListModelRole { name: "title"; elementName: "title" }
        XmlListModelRole { name: "imageSource"; elementName: "enclosure"; attributeName: "url"; }
    }

    ListView {
        id: listView
        anchors.fill: parent
        model: imageModel
        delegate: imageDelegate
    }
}

Списки с разделами

Иногда данные в списке могут быть разделены на разделы. Например, разделен может быть список контактов на разделы по каждой букве алфавита или музыкальные треки по альбомам. С помощью ListView можно разделить плоский список на категории, что обеспечивает большую глубину взаимодействия.

Списки с разделами

Чтобы использовать разделы, необходимо настроить section.property и section.criteria. Свойство section.property определяет, какое свойство использовать для разделения содержимого на разделы. Здесь важно знать, что модель должна быть отсортирована таким образом, чтобы каждый раздел состоял из непрерывно идущих элементов, иначе одно и то же имя свойства может появиться в нескольких местах.

Для section.criteria можно задать либо ViewSection.FullString, либо ViewSection.FirstCharacter. Первый является значением по умолчанию и может использоваться для моделей, имеющих четкие разделы, например, треки музыкальных альбомов. Последний принимает первый символ свойства и означает, что для него можно использовать любое свойство. Наиболее распространенным примером для этого является фамилия контакта в телефонной книге.

Когда разделы определены, доступ к ним можно получить из каждого элемента с помощью прикрепленных свойств ListView.section, ListView.previousSection и ListView.nextSection. Используя эти свойства, можно определить первый и последний элемент раздела и действовать соответствующим образом.

Также возможно назначить компонент делегата раздела свойству section.delegate представления ListView. Это создает делегат заголовка раздела, который вставляется перед любыми элементами раздела. Компонент делегата может получить доступ к имени текущего раздела, используя прикрепленное свойство section.

Пример ниже демонстрирует концепцию разделов, показывая список космонавтов, разделенных на секции по их национальности. В качестве свойства section.property используется nation. Компонент section.delegate, sectionDelegate, показывает заголовок для каждого раздела, отображая название нации. В каждом разделе имена космонавтов отображаются с помощью компонента spaceManDelegate.

import QtQuick
import "../common"

Background {
    width: 300
    height: 290

    ListView {
        anchors.fill: parent
        anchors.margins: 20

        clip: true

        model: spaceMen

        delegate: spaceManDelegate

        section.property: "nation"
        section.delegate: sectionDelegate
    }

    Component {
        id: spaceManDelegate

        Item {
            id: spaceManWrapper
            required property string name
            width: ListView.view.width
            height: 20
            Text {
                anchors.left: parent.left
                anchors.verticalCenter: parent.verticalCenter
                anchors.leftMargin: 8
                font.pixelSize: 12
                text: spaceManWrapper.name
                color: '#1f1f1f'
            }
        }
    }

    Component {
        id: sectionDelegate

        BlueBox {
            id: sectionWrapper
            required property string section
            width: ListView.view ? ListView.view.width : 0
            height: 20
            text: sectionWrapper.section
            fontColor: '#e0e0e0'
        }
    }


    ListModel {
        id: spaceMen

        ListElement { name: "Abdul Ahad Mohmand"; nation: "Afganistan"; }
        ListElement { name: "Marcos Pontes"; nation: "Brazil"; }
        ListElement { name: "Alexandar Panayotov Alexandrov"; nation: "Bulgaria"; }
        ListElement { name: "Georgi Ivanov"; nation: "Bulgaria"; }
        ListElement { name: "Roberta Bondar"; nation: "Canada"; }
        ListElement { name: "Marc Garneau"; nation: "Canada"; }
        ListElement { name: "Chris Hadfield"; nation: "Canada"; }
        ListElement { name: "Guy Laliberte"; nation: "Canada"; }
        ListElement { name: "Steven MacLean"; nation: "Canada"; }
        ListElement { name: "Julie Payette"; nation: "Canada"; }
        ListElement { name: "Robert Thirsk"; nation: "Canada"; }
        ListElement { name: "Bjarni Tryggvason"; nation: "Canada"; }
        ListElement { name: "Dafydd Williams"; nation: "Canada"; }
    }
}

ObjectModel

В некоторых случаях вы можете захотеть использовать представление списка для большого набора различных элементов. Вы можете решить эту проблему, используя динамический QML и загрузчик Loader, но другой вариант – использовать ObjectModel из модуля QtQml.Models. Объектная модель отличается от других моделей тем, что позволяет размещать рядом с моделью фактические визуальные элементы. Таким образом, представлению не нужен делегат.

ObjectModel

В приведенном ниже примере мы поместили три элемента Rectangle в ObjectModel. Однако один прямоугольник имеет дочерний элемент Text, а последний имеет закругленные углы. Это привело бы к модели табличного стиля, использующей что-то вроде ListModel. Это также привело бы к пустым элементам Text в модели.

import QtQuick
import QtQml.Models

Rectangle {
    width: 320
    height: 320
    
    gradient: Gradient {
        GradientStop { position: 0.0; color: "#f6f6f6" }
        GradientStop { position: 1.0; color: "#d7d7d7" }
    }
    
    ObjectModel {
        id: itemModel
        
        Rectangle { height: 60; width: 80; color: "#157efb" }
        Rectangle { height: 20; width: 300; color: "#53d769" 
            Text { anchors.centerIn: parent; color: "black"; text: "Hello QML" }
        }
        Rectangle { height: 40; width: 40; radius: 10; color: "#fc1a1c" }
    }
    
    ListView {
        anchors.fill: parent
        anchors.margins: 10
        spacing: 5
        
        model: itemModel
    }
}

Другой аспект ObjectModel заключается в том, что ее можно динамически заполнять с помощью методов get, insert, move, remove и clear. Таким образом, содержимое модели можно динамически генерировать из различных источников и по-прежнему легко отображать в одном представлении.

Модели с действиями

Тип ListElement поддерживает привязку функций Javascript к свойствам. Это означает, что вы можете поместить функции в модель. Это очень полезно при создании меню с действиями и других подобных конструкций.

Пример ниже демонстрирует это, имея модель городов, которые приветствуют вас по-разному. actionModel – это модель из четырех городов, но свойство hello привязано к функциям. Каждая функция принимает значение аргумента, но у вас может быть любое количество аргументов.

В делегате actionDelegate элемент MouseArea вызывает функцию hello как обычную функцию, что приводит к вызову соответствующего свойства hello в модели.

import QtQuick

Rectangle {
    width: 120
    height: 300

    gradient: Gradient {
        GradientStop { position: 0.0; color: "#f6f6f6" }
        GradientStop { position: 1.0; color: "#d7d7d7" }
    }
    
    ListModel {
        id: actionModel
        
        ListElement {
            name: "Copenhagen"
            hello: function(value) { console.log(value + ": You clicked Copenhagen!"); }
        }
        ListElement {
            name: "Helsinki"
            hello: function(value) { console.log(value + ": Helsinki here!"); }
        }
        ListElement {
            name: "Oslo"
            hello: function(value) { console.log(value + ": Hei Hei fra Oslo!"); }
        }
        ListElement {
            name: "Stockholm"
            hello: function(value) { console.log(value + ": Stockholm calling!"); }
        }
    }

    ListView {
        anchors.fill: parent
        anchors.margins: 20

        focus: true

        model: actionModel
        delegate: Rectangle {
            id: actionDelegate

            required property int index
            required property string name
            required property var hello

            width: ListView.view.width
            height: 40

            color: "#157efb"

            Text {
                anchors.centerIn: parent
                font.pixelSize: 10
                text: delegate.name
            }
            
            MouseArea {
                anchors.fill: parent
                onClicked: delegate.hello(delegate.index)
            }
        }

        spacing: 5
        clip: true
    }
}

Настройка производительности

Воспринимаемая производительность представления модели очень сильно зависит от времени, необходимого для подготовки новых делегатов. Например, при прокрутке ListView вниз делегаты добавляются сразу за пределами представления снизу и удаляются, как только они выходят из поля зрения выше представления. Это становится очевидным, если для свойства clip установлено значение false. Если для инициализации делегатов требуется слишком много времени, это станет заметным для пользователя, как только представление будет прокручиваться слишком быстро.

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

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

Чтобы исправить две последние проблемы, рекомендуется использовать элементы загрузчика Loader. Их можно использовать для создания дополнительных элементов, когда они необходимы. Например, расширяющийся делегат может использовать Loader, чтобы отложить создание подробного представления до тех пор, пока оно не понадобится. По той же причине лучше свести количество JavaScript в каждом делегате к минимуму. Лучше позволить им вызывать сложные фрагменты JavaScript, которые находятся вне каждого делегата. Это сокращает время, затрачиваемое на компиляцию JavaScript при каждом создании делегата.

Подсказка

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

Теги

GUI / Графический интерфейс пользователяObjectModelPathViewQMLQtQtQuickQtQuick ControlsTableViewXmlListModelПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.