מימוש רשימת פריטים בעלי גובה משתנה ב C++/Qt

12/03/2015
C++

לאחרונה עזרתי ללקוח לבנות קוד המציג רשימת פריטים לקופה רושמת באמצעות Qt. המחלקה QListView היא הדרך להציג רשימת פריטים, אך במקרה של קופה רושמת חלק מהפריטים תופסים שורה בודדת אך פריטים מסוימים תופסים שתי שורות — למשל פריט שרוצים לציין לגבי הנחה מיוחדת או הערה (נסו להסתכל בחשבון בפעם הבאה שאתם בסופר לראות דוגמאות).

דרך טובה להציג רשימת פריטים שכל פריט כולל מידע שונה ומוצג באופן שונה היא ארכיטקטורת Model/View של Qt. באמצעות בניית Model, Delegate ומחלקה המחזיקה את פריטי המידע אפשר לממש כל רשימה שתרצו ולקבל שליטה מלאה על אופן הצגת כל פריט. 

כל הקוד המוצג בפוסט זמין אונליין בקישור:
https://github.com/ynonp/qt-listview-variable-height-items

1. הצגת הבעייה

המטרה שלנו היא לבנות רשימת פריטים גמישה שחלק מהפריטים בה תופסים יותר מקום מאחרים. כך הרשימה עשויה להיראות:

שימו לב כי הפריט השני (הבננות) תופס שתי שורות, והשורה השניה שלו איננה כוללת עמודת מחיר. 

2. ארכיטקטורת Model/View וכיצד היא עוזרת בבניית הפתרון

ארכיטקטורת Model/View של Qt מאפשרת הפרדה של קוד המציג רשימות ל-3 חלקים: מודל, תצוגה ו Delegate. תפקידו של המודל לנהל את הפריטים השונים ולאפשר טעינה של פריטים נוספים בזמן ריצה כתוצאה ממידע חדש שהגיע מהרשת או מפעילות המשתמש. מחלקת התצוגה משמשת להצגת המידע שנמצא במודל, וכדי להקל עלינו בהתאמה אישית של אופן התצוגה המחלקה משתמשת במחלקה נוספת שנקראת Delegate שתפקידה לצייר את פריטי המידע עצמם.

כדי לכתוב רשימה של פריטים באורך משתנה אנו נרצה לבנות אם המחלקות הבאות:

  1. מחלקה המייצגת פריט מידע וכוללת גישה קלה לשדות שלו. במקרה שלנו כל פריט מידע כולל שם, מחיר ושורת הערות אופציונאלית.
  2. מחלקת המודל המנהלת את אוסף פריטי המידע ומדווחת למערכת התצוגה בכל פעם שהתקבלו פריטי מידע חדשים או שפריטים קיימים השתנו.
  3. מחלקת ה Delegate שתפקידה ציור פריטי המידע. כאן יופיע הקוד שיבדוק האם פריט מידע כולל שורת הערות, ולפי זה יחליט כיצד לצייר את הפריט ומה יהיו מימדיו.
  4. קוד המחבר בין כל המחלקות שבנינו ונמצא במחלקה MainWindow.

3. מימוש מחלקת ListItem

המחלקה הראשונה כלל אינה קשורה לארכיטקטורת Model/View אך היא תעזור לנו להעביר מידע בין רכיבי האפליקציה. קראתי למחלקה ListItem מאחר והיא מייצגת פריט ברשימה. אין צורך לרשת מ QObject אך יש מספר אילוצים אחרים הנובעים מהצורך לשמור אובייקטים ממחלקה זו ברשימה:

  1. עלינו להגדיר בנאי ריק.
  2. עלינו להגדיר Copy Constructor.
  3. עלינו להגדיר אופרטור השמה.
  4. כדי לאפשר המרה בין אובייקטים מסוג ListItem לבין QVariant אנו נצטרך להשתמש ב Q_DECLARE_METATYPE. 

כך נראית המחלקה, תחילה קובץ ה h:

// listitem.h

#ifndef LISTITEM_H
#define LISTITEM_H
#include <QString>
#include <QMetaType>

class ListItem
{
public:
    ListItem(QString name, int price, QString comments);
    ListItem(const ListItem &other);
    ListItem();

public:
    const ListItem &operator=(const ListItem &other);

public:
    const QString &getName() const;
    int getPrice() const;
    const QString &getComments() const;

private:
    QString _name;
    int _price;
    QString _comments;
};

Q_DECLARE_METATYPE(ListItem);

#endif // LISTITEM_H

ולאחר מכן קובץ ה cpp:

#include "listitem.h"

ListItem::ListItem(QString name, int price, QString comments):
    _name(name),
    _price(price),
    _comments(comments)
{
}

ListItem::ListItem(const ListItem &other):
    _name(other.getName()),
    _price(other.getPrice()),
    _comments(other.getComments())
{
}

ListItem::ListItem()
{
}


const ListItem &ListItem::operator=(const ListItem &other)
{
    _comments = other.getComments();
    _name = other.getName();
    _price = other.getPrice();
    return *this;
}

const QString &ListItem::getName() const
{
    return _name;
}

int ListItem::getPrice() const
{
    return _price;
}

const QString &ListItem::getComments() const
{
    return _comments;
}

4. בניית המודל

בארכיטקטורת Model/View המודל יורש מ QAbstractItemModel ומנהל את הפריטים ברשימה. כל שינוי פריטים, הוספה של פריטים חדשים ואף הגדרת מאפיינים על הפריטים (למשל, האם ניתן לשנות פריט או לבחור אותו) נמצאים במחלקה זו. 

מאחר ועלינו לבנות מודל של רשימה עדיף לרשת מ QAbstractListModel ולקבל חלק מהמתודות כבר ממומשות. ירושה מ QAbstractListModel מחייבת אותנו לממש בסך הכל שתי מתודות: data ו rowCount. אני בחרתי להחזיק רשימת פריטים בתוך מחלקת המודל כך ש data רק צריך להחזיר אלמנט מתוך הרשימה ו rowCount את גודל הרשימה.

שימו לב למימוש הפונקציה data:

QVariant MyModel::data(const QModelIndex & index, int role) const
{
    ListItem item = _items.at(index.row());

    if ( role == Qt::DisplayRole )
    {
        return item.getName();
    }
    else if ( role == Qt::UserRole )
    {
        QVariant res;
        res.setValue(item);
        return res;
    }
    else
    {
        return QVariant::Invalid;
    }
}

הפונקציה מקבלת אינדקס לפריט ופרמטר נוסף הנקרא Role. ארכיטקטורת Model/View בנויה על כך שהמודל מנהל את המידע ואובייקט התצוגה מציג אותו. המשתנה role מאפשר להחזיר אספקטים שונים של אותו אלמנט, וכך לקבל יותר גמישות בחיבור בין המודל למחלקת ההצגה. לדוגמא הערך Qt::DecorationRole  מאפשר להגדיר אייקון לפריט. מחלקת התצוגה מבקשת מהמודל את ״תפקיד״ הקישוט, המודל מחזיר את התמונה לציור בתור אייקון וכך אפשר לצייר אייקון בעת ציור פריט. 

באחריות מחלקת ה Delegate לתשאל את המודל ולבקש את התפקידים השונים של כל פריט. 

אם אתם מוכנים לחיות עם קצת יותר צימות בין מחלקות, אפשר להשתמש ב UserRole כדי להחזיר אובייקט מידע כלשהו. כך בדוגמא בחרתי להעביר אובייקט ListItem בין המודל למחלקת הציור.

5. מימוש ה Delegate

החלק האחרון שעלינו לממש הוא מחלקת הציור, כלומר ה Delegate. מחלקה זו אחראית על תשאול המודל לגבי מאפייני פריט באינדקס מסוים והצגתו למשתמש. אנחנו נצטרך לממש שתי פונקציות במחלקה: האחת, sizeHint, מחזירה גודל של פריט והשניה, paint מציירת את הפריט.

נתחיל עם המתודה sizeHint מאחר והיא יותר פשוטה:

QSize	MyDelegate::sizeHint(const QStyleOptionViewItem & option, const QModelIndex & index) const
{
    QVariant itemVar = index.data(Qt::UserRole);
    ListItem item = itemVar.value<ListItem>();

    if ( item.getComments().isEmpty() )
    {
        return QSize(option.rect.width(), 50);
    }
    else
    {
        return QSize(option.rect.width(), 100);
    }
}

בתחילת הקוד אנו שולפים את אובייקט ה ListItem שעלינו לבדוק באמצעות העברת UserRole למודל. הפרמטר יועבר לפונקציה data של המודל, ממנה יוחזר פריט המידע. כעת אפשר לבדוק האם פריט זה כולל שורת הערות (ואז להחזיר גודל של פריט גבוה) או אינו כולל, ואז מחזירים גודל קטן יותר.

הפונקציה paint קצת יותר מורכבת ולכן פיצלתי אותה לשתי פונקציות ציור: האחת לפריטים עם הערות והשניה בלי. הציור עצמו מבוצע באמצעות QPainter שכבר מגיע מאותחל לפונקציה, וכל שעלינו לעשות הוא להשתמש בו כדי לצייר את הפריט המסוים עליו אנו עובדים. כך נראה הקוד:

void MyDelegate::paintItemWithComments(QPainter * painter, const QStyleOptionViewItem & option, const ListItem &item) const
{
    // item with comments
    QRect topHalf = option.rect.adjusted(0, 0, 0, -1 * (option.rect.height() / 2));
    QRect bottomHalf = option.rect.adjusted(0, option.rect.height() / 2, 0, 0);

    QRect itemName = topHalf.adjusted(0, 0, -1 * option.rect.width() / 3, 0);
    QRect itemPrice = topHalf.adjusted(itemName.right(), 0, 0, 0);
    QRect itemComments = bottomHalf;

    painter->fillRect(bottomHalf, Qt::lightGray);

    painter->drawText(itemName, Qt::AlignVCenter, item.getName());
    painter->drawText(itemPrice, Qt::AlignVCenter, QString::number(item.getPrice()));
    painter->drawText(itemComments, Qt::AlignVCenter, item.getComments());
}

void MyDelegate::paintItemNoComments(QPainter * painter, const QStyleOptionViewItem & option, const ListItem &item) const
{
    // item with comments
    QRect itemName = option.rect.adjusted(0, 0, -1 * option.rect.width() / 3, 0);
    QRect itemPrice = option.rect.adjusted(itemName.right(), 0, 0, 0);

    painter->drawText(itemName, Qt::AlignVCenter, item.getName());
    painter->drawText(itemPrice, Qt::AlignVCenter, QString::number(item.getPrice()));

}

void MyDelegate::paint(QPainter * painter, const QStyleOptionViewItem & option, const QModelIndex & index) const
{
    QVariant itemVar = index.data(Qt::UserRole);
    ListItem item = itemVar.value<ListItem>();

    if ( !item.getComments().isEmpty())
    {
        paintItemWithComments(painter, option, item);
    }
    else
    {
        paintItemNoComments(painter, option, item);
    }
}

 

6. חיבור החלקים השונים במחלקה MainWindow

החלקים של ארכיטקטורת Model/View מתחברים יחד באמצעות יצירת Model ו Delegate והעברתם אל ה View.
אפשר לראות את קוד החיבור בבנאי המחלקה MainWindow:

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);
    _model = new MyModel(this);
    _delegate = new MyDelegate(this);

    ui->listView->setModel(_model);
    ui->listView->setItemDelegate(_delegate);
}

7. סיכום וקריאה נוספת

השימוש בארכיטקטורת Model/View אפשר לנו לבנות רשימת פריטים בעלי גובה משתנה וגם להרוויח קוד נקי יותר:

  1. כל פריט מידע מיוצג באובייקט מסוג ListItem (כולל שם, מחיר ושדה הערות).
  2. הפריטים כולם מנוהלים במחלקת MyModel. זה נוח כי אז אתה לא חייב ליצור את כל הפריטים מההתחלה ויכול לטעון פריטים נוספים כתוצאה מפעולות משתמש (למשל גלילה למטה).
  3. קוד הציור לפריט נמצא במחלקה MyDelegate. יש שם חלוקה לפונקציה המציירת פריט עם הערות ופונקציה אחרת לציור פריט ללא הערות. גם SizeHint שונה כך שפריט עם הערות מקבל שורה גבוהה יותר.

ההפרדה שקיימת ב Qt בין החלקים השונים בארכיטקטורת Model/View היא שמאפשרת בניית תצוגות מותאמות אישית כמעט ללא מאמץ. אנו כותבים רק את הקוד הרלוונטי לבעייה שלנו ו Qt דואג לשאר. 

הקוד המלא להצגת הרשימה זמין בקישור:
https://github.com/ynonp/qt-listview-variable-height-items

ניתן לקרוא עוד על ארכיטקטורת Model/View בקישור:
http://doc.qt.io/qt-5/modelview.html