Introduce SearchField for Quick Controls

Add a new QQuickSearchField as part of the Qt Quick Controls to
simplify implementing search functionality for lists of items.

Task-number: QTBUG-126188
Change-Id: I634131161447616a2d66e7f301bd8a24adac2d7f
Reviewed-by: Jan Arve Sæther <jan-arve.saether@qt.io>
This commit is contained in:
Dilek Akcay 2024-12-30 16:14:56 +01:00 committed by Jan Arve Sæther
parent 5f9eafcf39
commit 8576166145
16 changed files with 1673 additions and 0 deletions

View File

@ -10,6 +10,7 @@ qt_standard_project_setup(REQUIRES 6.8)
qt_add_executable(galleryexample WIN32 MACOSX_BUNDLE
gallery.cpp
qmlsortfilterproxymodel.h
)
qt_add_qml_module(galleryexample
@ -36,6 +37,7 @@ qt_add_qml_module(galleryexample
"pages/ScrollBarPage.qml"
"pages/ScrollIndicatorPage.qml"
"pages/ScrollablePage.qml"
"pages/SearchFieldPage.qml"
"pages/SliderPage.qml"
"pages/SpinBoxPage.qml"
"pages/StackViewPage.qml"

View File

@ -24,6 +24,7 @@ RESOURCES += \
pages/ScrollablePage.qml \
pages/ScrollBarPage.qml \
pages/ScrollIndicatorPage.qml \
pages/SearchFieldPage.qml \
pages/SliderPage.qml \
pages/SpinBoxPage.qml \
pages/StackViewPage.qml \

View File

@ -149,6 +149,7 @@ ApplicationWindow {
ListElement { title: qsTr("RangeSlider"); source: "qrc:/pages/RangeSliderPage.qml" }
ListElement { title: qsTr("ScrollBar"); source: "qrc:/pages/ScrollBarPage.qml" }
ListElement { title: qsTr("ScrollIndicator"); source: "qrc:/pages/ScrollIndicatorPage.qml" }
ListElement { title: qsTr("SearchField"); source: "qrc:/pages/SearchFieldPage.qml" }
ListElement { title: qsTr("Slider"); source: "qrc:/pages/SliderPage.qml" }
ListElement { title: qsTr("SpinBox"); source: "qrc:/pages/SpinBoxPage.qml" }
ListElement { title: qsTr("StackView"); source: "qrc:/pages/StackViewPage.qml" }

View File

@ -0,0 +1,44 @@
// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick.Controls
ScrollablePage {
id: page
Column {
spacing: 40
width: parent.width
Label {
width: parent.width
wrapMode: Label.Wrap
horizontalAlignment: Qt.AlignHCenter
text: qsTr("SearchField is a styled text input for searching, typically "
+ "with a magnifier and clear icon.")
}
ListModel {
id: colorModel
ListElement { color: "blue" }
ListElement { color: "green" }
ListElement { color: "red" }
ListElement { color: "yellow" }
ListElement { color: "orange" }
ListElement { color: "purple" }
}
SortFilterProxyModel {
id: colorFilter
sourceModel: colorModel
filterRegularExpression: RegExp(colorSearch.text, "i")
}
SearchField {
id: colorSearch
suggestionModel: colorFilter
anchors.horizontalCenter: parent.horizontalCenter
}
}
}

View File

@ -0,0 +1,16 @@
// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#ifndef QMLSORTFILTERPROXYMODEL_H
#define QMLSORTFILTERPROXYMODEL_H
#include <QtQml/qqmlregistration.h>
#include <QSortFilterProxyModel>
class QmlSortFilterProxyModel {
Q_GADGET
QML_FOREIGN(QSortFilterProxyModel)
QML_NAMED_ELEMENT(SortFilterProxyModel)
};
#endif // QMLSORTFILTERPROXYMODEL_H

View File

@ -46,6 +46,7 @@ set(qml_files
"ScrollBar.qml"
"ScrollIndicator.qml"
"ScrollView.qml"
"SearchField.qml"
"SelectionRectangle.qml"
"Slider.qml"
"SpinBox.qml"
@ -210,6 +211,7 @@ set(qtquickcontrols2basicstyle_resource_files
"images/check@2x.png"
"images/check@3x.png"
"images/check@4x.png"
"images/close_circle.png"
"images/dial-indicator.png"
"images/dial-indicator@2x.png"
"images/dial-indicator@3x.png"
@ -222,6 +224,7 @@ set(qtquickcontrols2basicstyle_resource_files
"images/drop-indicator@2x.png"
"images/drop-indicator@3x.png"
"images/drop-indicator@4x.png"
"images/search-magnifier.png"
)
qt_internal_add_resource(QuickControls2Basic "qtquickcontrols2basicstyle"

View File

@ -0,0 +1,126 @@
// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.impl
import QtQuick.Templates as T
T.SearchField {
id: control
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
searchIndicator.implicitIndicatorHeight + topPadding + bottomPadding,
clearIndicator.implicitIndicatorHeight + topPadding + bottomPadding)
leftPadding: padding + (control.mirrored || !searchIndicator.indicator || !searchIndicator.indicator.visible ? 0 : searchIndicator.indicator.width + spacing)
rightPadding: padding + (control.mirrored || !clearIndicator.indicator || !clearIndicator.indicator.visible ? 0 : clearIndicator.indicator.width + spacing)
delegate: ItemDelegate {
width: ListView.view.width
text: model[control.textRole]
palette.text: control.palette.text
palette.highlightedText: control.palette.highlightedText
font.weight: control.currentIndex === index ? Font.DemiBold : Font.Normal
highlighted: control.currentIndex === index
hoverEnabled: control.hoverEnabled
required property var model
required property int index
}
searchIndicator.indicator: Rectangle {
implicitWidth: 28
implicitHeight: 28
x: !control.mirrored ? 3 : control.width - width - 3
y: control.topPadding + (control.availableHeight - height) / 2
color: control.palette.button
ColorImage {
x: (parent.width - width) / 2
y: (parent.height - height) / 2
color: control.palette.dark
defaultColor: "#353637"
source: "qrc:/qt-project.org/imports/QtQuick/Controls/Basic/images/search-magnifier.png"
opacity: enabled ? 1 : 0.3
}
}
clearIndicator.indicator: Rectangle {
implicitWidth: 28
implicitHeight: 28
x: control.mirrored ? 3 : control.width - width - 3
y: control.topPadding + (control.availableHeight - height) / 2
visible: control.text.length > 0
color: control.palette.button
ColorImage {
x: (parent.width - width) / 2
y: (parent.height - height) / 2
color: control.palette.dark
defaultColor: "#353637"
source: "qrc:/qt-project.org/imports/QtQuick/Controls/Basic/images/close_circle.png"
opacity: enabled ? 1 : 0.3
}
}
contentItem: T.TextField {
leftPadding: control.searchIndicator.indicator && !control.mirrored ? 6 : 0
rightPadding: control.clearIndicator.indicator && !control.mirrored ? 6 : 0
topPadding: 6 - control.padding
bottomPadding: 6 - control.padding
text: control.text
color: control.palette.text
selectionColor: control.palette.highlight
selectedTextColor: control.palette.highlightedText
verticalAlignment: TextInput.AlignVCenter
}
background: Rectangle {
implicitWidth: 200
implicitHeight: 40
color: control.palette.button
border.width: (control.activeFocus || control.contentItem.activeFocus) ? 2 : 1
border.color: (control.activeFocus || control.contentItem.activeFocus) ? control.palette.highlight : control.palette.mid
}
popup: T.Popup {
y: control.height
width: control.width
height: Math.min(contentItem.implicitHeight, control.Window.height - control.y - control.height - control.padding)
topMargin: 6
bottomMargin: 6
palette: control.palette
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: control.delegateModel
currentIndex: control.currentIndex
highlightMoveDuration: 0
Rectangle {
z: 10
width: parent.width
height: parent.height
color: "transparent"
border.color: control.palette.mid
}
T.ScrollIndicator.vertical: ScrollIndicator { }
}
background: Rectangle {
color: control.palette.window
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -83,6 +83,7 @@ qt_internal_add_qml_module(QuickTemplates2
qquickscrollbar_p_p.h
qquickscrollindicator.cpp qquickscrollindicator_p.h
qquickscrollview.cpp qquickscrollview_p.h
qquicksearchfield.cpp qquicksearchfield_p.h
qquickshortcutcontext.cpp
qquickshortcutcontext_p_p.h
qquickslider.cpp qquickslider_p.h

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,115 @@
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
#ifndef QQUICKSEARCHFIELD_P_H
#define QQUICKSEARCHFIELD_P_H
//
// W A R N I N G
// -------------
//
// This file is not part of the Qt API. It exists purely as an
// implementation detail. This header file may change from version to
// version without notice, or even be removed.
//
// We mean it.
//
#include <QtQuickTemplates2/private/qquickcontrol_p.h>
QT_BEGIN_NAMESPACE
class QQuickSearchFieldPrivate;
class QQuickTextField;
class QQuickPopup;
class QQmlInstanceModel;
class QQuickIndicatorButton;
class Q_QUICKTEMPLATES2_EXPORT QQuickSearchField : public QQuickControl
{
Q_OBJECT
Q_PROPERTY(QVariant suggestionModel READ suggestionModel WRITE setSuggestionModel
NOTIFY suggestionModelChanged FINAL)
Q_PROPERTY(QQmlInstanceModel *delegateModel READ delegateModel NOTIFY delegateModelChanged FINAL)
Q_PROPERTY(int suggestionCount READ suggestionCount NOTIFY suggestionCountChanged FINAL)
Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged
FINAL)
Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged FINAL)
Q_PROPERTY(QString textRole READ textRole WRITE setTextRole NOTIFY textRoleChanged FINAL)
Q_PROPERTY(bool live READ isLive WRITE setLive NOTIFY liveChanged)
Q_PROPERTY(QQuickIndicatorButton *searchIndicator READ searchIndicator CONSTANT FINAL)
Q_PROPERTY(QQuickIndicatorButton *clearIndicator READ clearIndicator CONSTANT FINAL)
Q_PROPERTY(QQuickPopup *popup READ popup WRITE setPopup NOTIFY popupChanged FINAL)
Q_PROPERTY(QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY delegateChanged FINAL)
QML_NAMED_ELEMENT(SearchField)
QML_ADDED_IN_VERSION(6, 10)
public:
explicit QQuickSearchField(QQuickItem *parent = nullptr);
~QQuickSearchField();
QVariant suggestionModel() const;
void setSuggestionModel(const QVariant &model);
QQmlInstanceModel *delegateModel() const;
int suggestionCount() const;
int currentIndex() const;
void setCurrentIndex(int index);
QString text() const;
void setText(const QString &text);
QString textRole() const;
void setTextRole(const QString &textRole);
bool isLive() const;
void setLive(const bool live);
QQuickIndicatorButton *searchIndicator() const;
QQuickIndicatorButton *clearIndicator() const;
QQuickPopup *popup() const;
void setPopup(QQuickPopup *popup);
QQmlComponent *delegate() const;
void setDelegate(QQmlComponent *delegate);
Q_SIGNALS:
void activated(int index);
void accepted();
void searchTriggered();
void textEdited();
void suggestionModelChanged();
void delegateModelChanged();
void suggestionCountChanged();
void currentIndexChanged();
void textChanged();
void textRoleChanged();
void liveChanged();
void popupChanged();
void delegateChanged();
void searchButtonPressed();
void clearButtonPressed();
protected:
bool eventFilter(QObject *object, QEvent *event) override;
void focusInEvent(QFocusEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void classBegin() override;
void componentComplete() override;
void contentItemChange(QQuickItem *newItem, QQuickItem *oldItem) override;
void itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &data) override;
private:
Q_DISABLE_COPY(QQuickSearchField)
Q_DECLARE_PRIVATE(QQuickSearchField)
};
QT_END_NAMESPACE
#endif // QQUICKSEARCHFIELD_P_H

View File

@ -0,0 +1,230 @@
// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
import QtQuick
import QtQuick.Window
import QtTest
import QtQuick.Controls
import Qt.test.controls
TestCase {
id: testCase
width: 400
height: 400
visible: true
when: windowShown
name: "SearchField"
Component {
id: signalSpy
SignalSpy { }
}
Component {
id: searchField
SearchField { }
}
Component {
id: searchText
SearchField {
TextField{ }
}
}
function init() {
failOnWarning(/.?/)
}
function test_defaults() {
let control = createTemporaryObject(searchField, testCase)
verify(control)
compare(control.suggestionModel, undefined)
compare(control.suggestionCount, 0)
compare(control.currentIndex, -1)
compare(control.text, "")
compare(control.textRole, "")
compare(control.live, true)
verify(control.delegate)
verify(control.popup)
}
// TO-DO: Implement SFPM logic after 6.10
// ListModel {
// id: specialCharModels
// ListElement { text: "" }
// ListElement { text: "Pi: π (3.14)"; }
// ListElement { text: "Math: "; }
// ListElement { text: "Emoji: 😃🎉🔥"; }
// ListElement { text: "Currency: ¥ $"; }
// ListElement { text: "α β γ"; }
// ListElement { text: "Привет"; }
// ListElement { text: "مرحبًا"; }
// ListElement { text: ""; }
// ListElement { text: "שלום"; }
// ListElement { text: "Brackets: { [ ( < > ) ] }"; }
// }
// function test_specialCharacters() {
// let control = createTemporaryObject(searchField, testCase)
// verify(control)
// control.suggestionModel = specialCharModels
// let textItem = control.contentItem
// textItem.text = "e"
// compare(control.text, "e")
// compare(control.suggestionCount, 3)
// compare(control.currentIndex, 0)
// compare(control.popup.visible, true)
// textItem.text = "П"
// compare(control.text, "П")
// compare(control.suggestionCount, 1)
// compare(control.currentIndex, 0)
// compare(control.popup.visible, true)
// textItem.text = "🎉"
// compare(control.text, "🎉")
// compare(control.suggestionCount, 1)
// compare(control.currentIndex, 0)
// compare(control.popup.visible, true)
// }
ListModel {
id : fruitModel
ListElement { name: "Apple"; color: "green" }
ListElement { name: "Cherry"; color: "red" }
ListElement { name: "Banana"; color: "yellow" }
ListElement { name: "Orange"; color: "orange" }
ListElement { name: "WaterMelon"; color: "pink" }
}
function test_textRole() {
ignoreWarning(/Unable to assign QQmlDMAbstractItemModelData to QString/)
let control = createTemporaryObject(searchField, testCase)
verify(control)
control.suggestionModel = fruitModel
control.textRole = "name"
let textItem = control.contentItem
textItem.text = "a"
compare(control.text, "a")
compare(control.suggestionCount, 5)
compare(control.popup.visible,true)
control.textRole = "color"
textItem.text = "r"
compare(control.text, "r")
compare(control.suggestionCount, 5)
compare(control.popup.visible,true)
}
Component {
id: suggestion
SearchField {
onTextEdited: {
if (text === "a") {
suggestionModel = ["Apple", "Apricot"]
} else if (text === "c") {
suggestionModel = ["Cherry", "Coconut", "Cranberry"]
}
}
}
}
function test_suggestionPopup() {
let control = createTemporaryObject(suggestion, testCase)
verify(control)
compare(control.popup.visible, false)
let textItem = control.contentItem
textItem.text = "a"
compare(control.suggestionCount, 2)
compare(control.currentIndex, 0)
compare(control.popup.visible, true)
textItem.text = "c"
compare(control.suggestionCount, 3)
compare(control.currentIndex, 0)
compare(control.popup.visible, true)
}
function test_textEdited() {
let control = createTemporaryObject(searchField, testCase)
verify(control)
let textEditedSpy = signalSpy.createObject(control, {target: control, signalName: "textEdited"})
verify(textEditedSpy.valid)
let searchTriggeredSpy = signalSpy.createObject(control, {target: control, signalName: "searchTriggered"})
verify(searchTriggeredSpy.valid)
control.live = true
let textItem = control.contentItem
textItem.text = "a"
compare(control.text, "a")
compare(textEditedSpy.count, 1)
compare(searchTriggeredSpy.count, 1)
}
function test_arrowKeys() {
ignoreWarning(/Unable to assign QQmlDMAbstractItemModelData to QString/)
let control = createTemporaryObject(searchField, testCase)
verify(control)
let openedSpy = signalSpy.createObject(control, {target: control.popup, signalName: "opened"})
verify(openedSpy.valid)
let closedSpy = signalSpy.createObject(control, {target: control.popup, signalName: "closed"})
verify(closedSpy.valid)
let acceptedSpy = signalSpy.createObject(control, {target: control, signalName: "accepted"})
verify(closedSpy.valid)
let searchTriggeredSpy = signalSpy.createObject(control, {target: control, signalName: "searchTriggered"})
verify(searchTriggeredSpy.valid)
control.forceActiveFocus()
verify(control.activeFocus)
control.suggestionModel = fruitModel
control.textRole = "name"
let textItem = control.contentItem
textItem.text = "a"
compare(control.popup.visible, true)
keyClick(Qt.Key_Down)
compare(control.currentIndex, 1)
keyClick(Qt.Key_Down)
compare(control.currentIndex, 2)
keyClick(Qt.Key_Up)
compare(control.currentIndex, 1)
keyClick(Qt.Key_Enter)
compare(control.text, "Cherry")
compare(acceptedSpy.count, 1)
compare(searchTriggeredSpy.count, 2)
keyClick(Qt.Key_Back)
compare(control.popup.visible, false)
keyClick(Qt.Key_Escape)
compare(control.text, "")
}
}

View File

@ -0,0 +1,84 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Proxy 1.0
GridLayout{
width: 500
height: 300
anchors.fill: parent
rows: 4
flow: GridLayout.TopToBottom
SearchField {
live: false
}
// TO-DO: Add a test case for autoSuggest property
// SearchField {
// autoSuggest: true
// }
SearchField {
suggestionModel: ListModel {
ListElement { color: "blue" }
ListElement { color: "green" }
ListElement { color: "red" }
ListElement { color: "yellow" }
ListElement { color: "orange" }
ListElement { color: "purple" }
ListElement { color: "cyan" }
ListElement { color: "magenta" }
ListElement { color: "chartreuse" }
ListElement { color: "aquamarine" }
ListElement { color: "indigo" }
ListElement { color: "black" }
ListElement { color: "lightsteelblue" }
ListElement { color: "violet" }
ListElement { color: "grey" }
ListElement { color: "springgreen" }
ListElement { color: "salmon" }
ListElement { color: "blanchedalmond" }
ListElement { color: "forestgreen" }
ListElement { color: "pink" }
ListElement { color: "navy" }
ListElement { color: "goldenrod" }
ListElement { color: "crimson" }
ListElement { color: "turquoise" }
}
}
SearchField {
suggestionModel: ["January", "February", "March", "April", "May", "June", "July", "August",
"September", "October", "November", "December"]
}
SearchField {
suggestionModel: ListModel {
ListElement { name: "Apple"; color: "green" }
ListElement { name: "Cherry"; color: "red" }
ListElement { name: "Banana"; color: "yellow" }
ListElement { name: "Orange"; color: "orange" }
ListElement { name: "WaterMelon"; color: "pink" }
}
textRole: "color"
}
SearchField {
id: searchField
suggestionModel: QSortFilterProxyModel {
id: colorModel
sourceModel: ListModel {
ListElement { color: "blue" }
ListElement { color: "green" }
ListElement { color: "red" }
ListElement { color: "yellow" }
ListElement { color: "orange" }
ListElement { color: "purple" }
}
}
onTextChanged: {
colorModel.filterRegularExpression = new RegExp(searchField.text, "i")
}
}
}

View File

@ -11,6 +11,8 @@
#include <QtGui/QPalette>
#include <QtGui/QFont>
#include <QtQuickControls2/QQuickStyle>
#include <QSortFilterProxyModel>
#include <QQmlApplicationEngine>
#include <algorithm>
@ -153,6 +155,9 @@ void tst_Baseline_Controls::initTestCase()
qInfo("PlatformName computed to be : %s", qPrintable(platformName));
qInfo("Color Scheme computed as : %s", qPrintable(colorSchemeIdStr));
qInfo("Native style name is : %s", qPrintable(QQuickStyle::name()));
qmlRegisterAnonymousType<QAbstractItemModel>("Proxy", 1);
qmlRegisterType<QSortFilterProxyModel>("Proxy", 1, 0, "QSortFilterProxyModel");
}
void tst_Baseline_Controls::init()