aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMitch Curtis <mitch.curtis@qt.io>2023-02-24 16:40:45 +0800
committerMitch Curtis <mitch.curtis@qt.io>2023-03-31 15:00:31 +0800
commitd414c81f38d8fea47d91c9414bdecda9ea7761a1 (patch)
tree682ff816cb626dd374e48113078f6d4e784fcd70
parent8edc526f4362ba00246f192bc9e114c356c896b0 (diff)
Add an Android TimePickerDialog how-to
This is a highly requested control, yet its implementation varies greatly between devices and platforms. Rather than try to implement an API and UI that fits every use case (and implement whatever infrastructure may be required for offering separate mobile and desktop controls), offer a specialized example: an Android time picker. The goal is to provide a set of QML files that users can simply copy into their projects. The screenshots were taken of the whole window on a MacBook whose display DPR is 2, and then adapted with the following commands: convert uncropped-light.png -crop 870x1030+280+220 how-to-time-picker-light.png convert uncropped-dark.png -crop 870x1030+280+220 how-to-time-picker-dark.png mogrify -resize 50% how-to-time-picker-*.png find . -name "how-to-time-picker-*.png" -exec optipng -o 7 -strip all {} \; Task-number: QTBUG-51167 Task-number: QTBUG-109634 Pick-to: 6.5 Change-Id: Ie11913ba4a83673d7c2467066d9cfef772055c1d Reviewed-by: Jan Arve Sæther <jan-arve.saether@qt.io>
-rw-r--r--src/quick/doc/images/how-to-time-picker-dark.pngbin0 -> 26831 bytes
-rw-r--r--src/quick/doc/images/how-to-time-picker-light.pngbin0 -> 25659 bytes
-rw-r--r--src/quick/doc/src/qtquick-how-tos.qdoc19
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/CMakeLists.txt5
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimeComponentLabel.qml29
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePicker.qml317
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerDialog.qml88
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerLabel.qml126
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerMain.qml59
-rw-r--r--tests/auto/quick/doc/how-tos/how-to-qml/tst_how-to-qml.cpp297
10 files changed, 939 insertions, 1 deletions
diff --git a/src/quick/doc/images/how-to-time-picker-dark.png b/src/quick/doc/images/how-to-time-picker-dark.png
new file mode 100644
index 0000000000..87fa6bd2cd
--- /dev/null
+++ b/src/quick/doc/images/how-to-time-picker-dark.png
Binary files differ
diff --git a/src/quick/doc/images/how-to-time-picker-light.png b/src/quick/doc/images/how-to-time-picker-light.png
new file mode 100644
index 0000000000..6eb1aba2ef
--- /dev/null
+++ b/src/quick/doc/images/how-to-time-picker-light.png
Binary files differ
diff --git a/src/quick/doc/src/qtquick-how-tos.qdoc b/src/quick/doc/src/qtquick-how-tos.qdoc
index cbf0de2287..ced360de6a 100644
--- a/src/quick/doc/src/qtquick-how-tos.qdoc
+++ b/src/quick/doc/src/qtquick-how-tos.qdoc
@@ -16,6 +16,7 @@
\list
\li \l {Call a C++ function from QML when a Button is clicked}
\li \l {See which item has active focus}
+ \li \l {Create a time picker like Android's TimePickerDialog}
\endlist
@@ -54,4 +55,22 @@
This will print the item which currently has active focus to the console.
To ensure that the output is useful, give each item a descriptive
\l {QtObject::}{objectName}.
+
+
+ \section1 Create a time picker like Android's TimePickerDialog
+
+ We've prepared an example that consists of a few
+ \l {https://code.qt.io/cgit/qt/qtdeclarative.git/tree/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimeComponentLabel.qml?h=\QtMajorVersion.\QtMinorVersion}
+ {QML files} which demonstrate how to do this. They can be used
+ in your application in the following manner:
+
+ \snippet how-tos/how-to-qml/time-picker/TimePickerMain.qml file
+
+ \table
+ \row
+ \li \image how-to-time-picker-light.png
+ \caption TimePickerDialog in its light theme.
+ \li \image how-to-time-picker-dark.png
+ \caption TimePickerDialog in its dark theme.
+ \endtable
*/
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/CMakeLists.txt b/tests/auto/quick/doc/how-tos/how-to-qml/CMakeLists.txt
index 05c3132fe3..4013d0963d 100644
--- a/tests/auto/quick/doc/how-tos/how-to-qml/CMakeLists.txt
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/CMakeLists.txt
@@ -18,4 +18,9 @@ qt_add_qml_module(tst_how-to-qml
AUTO_RESOURCE_PREFIX
QML_FILES
active-focus-debugging/ActiveFocusDebuggingMain.qml
+ time-picker/TimeComponentLabel.qml
+ time-picker/TimePickerDialog.qml
+ time-picker/TimePickerMain.qml
+ time-picker/TimePickerLabel.qml
+ time-picker/TimePicker.qml
)
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimeComponentLabel.qml b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimeComponentLabel.qml
new file mode 100644
index 0000000000..ba2c019706
--- /dev/null
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimeComponentLabel.qml
@@ -0,0 +1,29 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls.Material
+
+Label {
+ id: root
+ fontSizeMode: Label.Fit
+ horizontalAlignment: Label.AlignHCenter
+ verticalAlignment: Label.AlignVCenter
+
+ Material.foreground: Material.theme === Material.Light
+ ? Material.color(Material.Indigo, !dim ? Material.Shade500 : Material.Shade100)
+ : Material.color(Material.Indigo, dim ? Material.Shade300 : Material.Shade100)
+
+ Layout.fillHeight: true
+
+ property bool dim: false
+ property alias interactive: tapHandler.enabled
+
+ signal tapped
+
+ TapHandler {
+ id: tapHandler
+ onTapped: root.tapped()
+ }
+}
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePicker.qml b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePicker.qml
new file mode 100644
index 0000000000..c838947af6
--- /dev/null
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePicker.qml
@@ -0,0 +1,317 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+//! [file]
+import QtQuick
+import QtQuick.Controls.Material
+
+Item {
+ id: root
+ implicitWidth: 250
+ implicitHeight: 250
+
+ enum Mode {
+ Hours,
+ Minutes
+ }
+
+ property int mode: TimePicker.Mode.Hours
+ property int hours
+ property int minutes
+ property bool is24Hour
+ property bool interactive: true
+
+ // The mode that the label delegates see, so that we can
+ // animate their opacity before their text changes.
+ property int __effectiveMode: TimePicker.Mode.Hours
+ // For 12 hour pickers, we can use 0 to 60 to represent all values.
+ property int __value: 0
+ // For 24 hour pickers, we need to store this extra flag.
+ property bool __is24HourValueSelected
+ // How many values the arm should snap to at a time.
+ readonly property int __stepSize: __getStepSize(mode)
+ readonly property int __to: 60
+ readonly property int __labelAngleStepSize: 360 / 12
+ property bool __switchingModes
+
+ // This signal could be used if TimePicker is used as a standalone component.
+ // It's emitted when the minute is selected.
+ signal accepted()
+
+ // Convenience for setting each property individually, but also
+ // ensures that the selector arm is properly rotated when there is
+ // a programmatic change in hours or minutes but not mode.
+ function openWith(mode, hours, minutes) {
+ root.mode = mode
+ root.hours = hours
+ root.minutes = minutes
+ __updateAfterModeOrTimeChange()
+ }
+
+ // Until QML gets private properties (QTBUG-11984), use the traditional
+ // double-underscore convention.
+ function __angleForValue(value: int): real {
+ return (value / __to) * 360
+ }
+
+ function __getStepSize(mode) {
+ return mode === TimePicker.Mode.Hours ? 5 : 1
+ }
+
+ function __updateAfterModeOrTimeChange() {
+ // We use a function for this rather than a binding, because we could be called before the
+ // __stepSize binding is evaluated.
+ if (mode === TimePicker.Mode.Hours) {
+ // modulo the hours value by __to because we want 60 (12) to be 0.
+ __value = hours * __getStepSize(mode) % __to
+ } else {
+ __value = minutes
+ }
+
+ __is24HourValueSelected = mode === TimePicker.Mode.Hours && hours >= 13
+ }
+
+ onModeChanged: __updateAfterModeOrTimeChange()
+
+ onIs24HourChanged: {
+ // Don't allow 24-hour values when we're not a 24-hour picker.
+ if (!is24Hour && hours > 12)
+ hours = 12
+ }
+
+ // Center dot.
+ Rectangle {
+ width: 6
+ height: 6
+ radius: width / 2
+ color: Material.primary
+ anchors.centerIn: parent
+ z: 1
+ }
+
+ Rectangle {
+ id: contentContainer
+ objectName: "contentContainer"
+ width: Math.min(parent.width, parent.height)
+ height: width
+ radius: width / 2
+ anchors.centerIn: parent
+ color: Material.theme === Material.Light ? "#eeeeee" : "#626262"
+
+ // Animate this so that we don't need an intermediate parent item for the
+ // labels to animate the opacity of that instead. That item would be required
+ // because we don't want to change the opacity of the contentContainer Rectangle.
+ property real labelOpacity: 1
+
+ function updateValueAfterPressPointChange() {
+ const y1 = height / 2
+ const x1 = width / 2
+ const y2 = tapHandler.point.position.y
+ const x2 = tapHandler.point.position.x
+ const yDistance = y2 - y1
+ const xDistance = x2 - x1
+ const angle = Math.atan2(yDistance, xDistance)
+
+ let angleInDegrees = (angle * (180 / Math.PI)) + 90.0
+ if (angleInDegrees < 0)
+ angleInDegrees = 360 + angleInDegrees
+
+ const normalisedAngle = angleInDegrees / 360.0
+ const rawValue = normalisedAngle * __to
+ // Snap to each step.
+ const steppedValue = Math.round(rawValue / __stepSize) * __stepSize
+ root.__value = steppedValue
+ // Account for the area where the angle wraps around from 360 to 0,
+ // otherwise values from 59.5 to 59.999[...] will register as 60 instead of 0.
+ if (rawValue > __to - __stepSize / 2)
+ root.__value = 0
+
+ const distanceFromCenter = Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2))
+ // Only allow selecting 24 hour values when it's in the correct mode.
+ root.__is24HourValueSelected = root.is24Hour && root.__effectiveMode === TimePicker.Mode.Hours
+ && distanceFromCenter < distanceFromCenterForLabels(true) + selectionIndicator.height * 0.5
+ }
+
+ // Returns the distance from our center at which a label should be centered over given is24Hour.
+ function distanceFromCenterForLabels(is24Hour) {
+ return contentContainer.radius - (is24Hour
+ ? selectionIndicator.height * 1.5 : selectionIndicator.height * 0.5)
+ }
+
+ states: [
+ State {
+ name: "hours"
+ when: root.mode === TimePicker.Mode.Hours
+ },
+ State {
+ name: "minutes"
+ when: root.mode === TimePicker.Mode.Minutes
+ }
+ ]
+
+ transitions: [
+ Transition {
+ // When the picker isn't interactive (e.g. when a dialog is opening),
+ // we shouldn't animate the opacity of the labels, as it looks wrong,
+ // and should only happen when switching between modes while the
+ // picker was already visible.
+ enabled: root.interactive
+
+ NumberAnimation {
+ target: contentContainer
+ property: "labelOpacity"
+ from: 1
+ to: 0
+ }
+
+ ScriptAction {
+ script: root.__effectiveMode = root.mode
+ }
+
+ NumberAnimation {
+ target: contentContainer
+ property: "labelOpacity"
+ from: 0
+ to: 1
+ }
+ },
+ Transition {
+ enabled: !root.interactive
+
+ // Since the transition above doesn't run when we're not interactive,
+ // we need to do the immediate property change here.
+ // See QTBUG-13268 for why we use a ScriptAction and not PropertyAction.
+ ScriptAction {
+ script: root.__effectiveMode = root.mode
+ }
+ }
+
+ ]
+
+ TapHandler {
+ id: tapHandler
+ gesturePolicy: TapHandler.ReleaseWithinBounds
+ // Don't allow input while switching modes, or a click on an hour could go through to a minute.
+ enabled: root.interactive && root.__effectiveMode === root.mode
+
+ onPointChanged: {
+ if (pressed) {
+ // Don't call this when not pressed, as the position will be invalid.
+ contentContainer.updateValueAfterPressPointChange()
+
+ // Update the value (like a "live" Slider) while the pointer position changes.
+ if (mode === TimePicker.Mode.Hours) {
+ root.hours = root.__value / root.__stepSize
+
+ if (root.hours === 0) {
+ // A value of 0 (when it's not a 24-hour picker) is 12.
+ // When it is a 24-hour picker, it's 0.
+ if (!root.__is24HourValueSelected)
+ root.hours = 12
+ } else if (root.__is24HourValueSelected) {
+ root.hours += 12
+ }
+ } else {
+ root.minutes = root.__value
+ }
+ } else {
+ // Select the value that was chosen in the press code above.
+ if (mode === TimePicker.Mode.Hours) {
+ mode = TimePicker.Mode.Minutes
+ } else {
+ // Does nothing in our example, but could be used to hide the dialog
+ // if it didn't have an OK button to accept it.
+ root.accepted()
+ }
+ }
+ }
+ }
+
+ // The line connecting the center dot to the selection indicator.
+ Rectangle {
+ id: selectionArm
+ objectName: "selectionArm"
+ width: 2
+ height: contentContainer.distanceFromCenterForLabels(root.__is24HourValueSelected)
+ color: Material.primary
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.verticalCenter
+ rotation: root.__angleForValue(root.__value)
+ transformOrigin: Item.Bottom
+ antialiasing: true
+
+ Rectangle {
+ id: selectionIndicator
+ objectName: "selectionIndicator"
+ width: 40
+ height: 40
+ radius: width / 2
+ color: Material.primary
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.top
+
+ Rectangle {
+ width: 4
+ height: 4
+ radius: width / 2
+ color: Material.color(Material.Indigo, Material.Shade100)
+ anchors.centerIn: parent
+ // Only show the circle within the indicator between minute labels.
+ visible: root.__effectiveMode === TimePicker.Mode.Minutes
+ && root.__value % 5 !== 0
+ }
+ }
+ }
+
+ Repeater {
+ id: labelRepeater
+ model: root.mode === TimePicker.Mode.Hours && root.is24Hour ? 24 : 12
+ delegate: Label {
+ id: labelDelegate
+ text: displayValue
+ font.pixelSize: Qt.application.font.pixelSize * (is24HourValue ? 0.85 : 1)
+ rotation: -rotationTransform.angle
+ opacity: contentContainer.labelOpacity
+ anchors.centerIn: parent
+
+ required property int index
+ // From 0 to 60.
+ readonly property int value: (index * 5) % root.__to
+ property int displayValue: root.__effectiveMode === TimePicker.Mode.Hours
+ ? index === 0
+ ? 12
+ : index === 12
+ ? 0
+ : index
+ : value
+ // The picker's current value can equal ours but we still might not be current -
+ // it depends on whether it's a 24 hour value or not.
+ readonly property bool current: root.__value === value && root.__is24HourValueSelected == is24HourValue
+ readonly property bool is24HourValue: index >= 12
+
+ Material.foreground: current
+ // When the selection arm is over us, invert our color so it's legible.
+ ? Material.color(Material.Indigo, Material.Shade100)
+ : root.Material.theme === Material.Light
+ ? is24HourValue ? "#686868" : "#484848"
+ : Material.color(Material.Indigo, is24HourValue ? Material.Shade300 : Material.Shade100)
+
+ transform: [
+ Translate {
+ // We're already centered in our parent, so we can use this function
+ // to determine our position, which doesn't need to be aware of our height -
+ // it just needs to tell us where our center position should be.
+ y: -contentContainer.distanceFromCenterForLabels(labelDelegate.is24HourValue)
+ },
+ Rotation {
+ id: rotationTransform
+ angle: root.__angleForValue(labelDelegate.value)
+ origin.x: labelDelegate.width / 2
+ origin.y: labelDelegate.height / 2
+ }
+ ]
+ }
+ }
+ }
+}
+//! [file]
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerDialog.qml b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerDialog.qml
new file mode 100644
index 0000000000..64c9ab0bb2
--- /dev/null
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerDialog.qml
@@ -0,0 +1,88 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+//! [file]
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls.Material
+
+Dialog {
+ id: root
+ standardButtons: Dialog.Ok | Dialog.Cancel
+ closePolicy: Dialog.NoAutoClose
+ // We don't want so much space between the picker and dialog buttons.
+ bottomPadding: 8
+
+ property int hours: 12
+ property int minutes: 0
+ property alias is24Hour: timePicker.is24Hour
+
+ property int __initialHours
+ property int __initialMinutes
+
+ signal timeAccepted
+ signal timeRejected
+
+ function openWithMode(mode) {
+ timePicker.openWith(mode !== undefined ? mode : TimePicker.Mode.Hours, hours, minutes)
+
+ __initialHours = hours
+ __initialMinutes = minutes
+
+ open()
+ }
+
+ onAccepted: {
+ root.hours = timePicker.hours
+ root.minutes = timePicker.minutes
+ root.timeAccepted()
+ }
+ onRejected: {
+ hours = __initialHours
+ minutes = __initialMinutes
+ // Also reset the picker's time so that the onIs24HourChanged handler below works as expected.
+ timePicker.hours = __initialHours
+ timePicker.minutes = __initialMinutes
+ root.timeRejected()
+ }
+
+ // If is24Hour changes programmatically (only while we're not open),
+ // make sure we adapt to any possible clamping it did in the transition from 24 hours to 12.
+ onIs24HourChanged: {
+ if (!opened)
+ root.hours = timePicker.hours
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 12
+
+ TimePickerLabel {
+ id: timeLabel
+ // Use TimePicker's time, because that is updated live, whereas our values
+ // are only changed once we've been accepted.
+ time: new Date(1970, 1, 1, timePicker.hours, timePicker.minutes)
+ hoursActive: timePicker.mode === TimePicker.Mode.Hours
+ showAmPm: !timePicker.is24Hour
+
+ Layout.fillWidth: true
+ // Push us down a bit so we're not so close to the top of the dialog.
+ Layout.topMargin: 8
+
+ onHoursSelected: timePicker.mode = TimePicker.Mode.Hours
+ onMinutesSelected: timePicker.mode = TimePicker.Mode.Minutes
+ }
+
+ TimePicker {
+ id: timePicker
+ objectName: "timePicker"
+ // Our TapHandler may handle the click event on the Label if we don't do this,
+ // causing an hour to be inadvertently selected.
+ interactive: root.opened
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+ }
+}
+//! [file]
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerLabel.qml b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerLabel.qml
new file mode 100644
index 0000000000..04ed73cdec
--- /dev/null
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerLabel.qml
@@ -0,0 +1,126 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+//! [file]
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls.Material
+
+Item {
+ id: root
+ // Use TextMetrics' boundingRect.width rather than a RowLayout's implicitWidth
+ // because the latter can cause TimePickerDialog to jump around when the label text changes.
+ implicitWidth: fullTextMetrics.boundingRect.width + amPmLayout.implicitWidth + 80
+ implicitHeight: fullTextMetrics.boundingRect.height
+
+ property var time
+ property bool am: true
+ property bool showAmPm: true
+
+ property bool hoursActive: true
+
+ property int fontPixelSize: Qt.application.font.pixelSize * 4
+
+ signal hoursSelected
+ signal minutesSelected
+ signal amSelected
+ signal pmSelected
+
+ TextMetrics {
+ id: fullTextMetrics
+ font: hoursLabel.font
+ text: "99:99"
+ }
+
+ TextMetrics {
+ id: hourTextMetrics
+ font.family: hoursLabel.font.family
+ font.pixelSize: hoursLabel.fontInfo.pixelSize
+ text: "99"
+ }
+
+ TimeComponentLabel {
+ id: hoursLabel
+ objectName: "hoursLabel"
+ text: Qt.formatTime(root.time, "hh")
+ font.pixelSize: root.fontPixelSize
+ // Avoid any jumping around by using a dedicated TextMetrics object
+ // for our label too, and position ourselves based on its width.
+ x: colonLabel.x - hourTextMetrics.boundingRect.width - 4
+ anchors.verticalCenter: parent.verticalCenter
+ dim: !root.hoursActive
+
+ onTapped: root.hoursSelected()
+ }
+
+ TimeComponentLabel {
+ id: colonLabel
+ text: ":"
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ font.pixelSize: root.fontPixelSize
+ dim: true
+ }
+
+ TimeComponentLabel {
+ id: minutesLabel
+ objectName: "minutesLabel"
+ text: Qt.formatTime(root.time, "mm")
+ font.pixelSize: root.fontPixelSize
+ anchors.left: colonLabel.right
+ anchors.leftMargin: 4
+ anchors.verticalCenter: parent.verticalCenter
+ dim: root.hoursActive
+
+ onTapped: root.minutesSelected()
+ }
+
+ ColumnLayout {
+ id: amPmLayout
+ visible: root.showAmPm
+ // We also need to avoid the AM/PM label jumping around,
+ // so rather than anchor it to the right of the minutes label,
+ // we use colonLabel's horizontal center (which never changes), and fullTextMetrics.
+ anchors.left: colonLabel.horizontalCenter
+ anchors.leftMargin: fullTextMetrics.boundingRect.width / 2 + 12
+ anchors.verticalCenter: minutesLabel.verticalCenter
+
+ spacing: 0
+
+ ToolButton {
+ objectName: "amButton"
+ text: qsTr("AM")
+ autoExclusive: true
+
+ Material.foreground: Material.color(Material.Indigo,
+ root.am ? Material.Shade500 : Material.Shade100)
+
+ // Set the size explicitly to ensure that the buttons aren't too large.
+ // We also add 1 to the width because we want square ripple effects, not round.
+ Layout.preferredWidth: (implicitWidth * 0.7) + 1
+ Layout.preferredHeight: (implicitHeight * 0.7)
+
+ onClicked: {
+ root.am = true
+ root.amSelected()
+ }
+ }
+ ToolButton {
+ objectName: "pmButton"
+ text: qsTr("PM")
+ autoExclusive: true
+
+ Material.foreground: Material.color(Material.Indigo,
+ !root.am ? Material.Shade500 : Material.Shade100)
+
+ Layout.preferredWidth: (implicitWidth * 0.7) + 1
+ Layout.preferredHeight: (implicitHeight * 0.7)
+
+ onClicked: {
+ root.am = false
+ root.pmSelected()
+ }
+ }
+ }
+}
+//! [file]
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerMain.qml b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerMain.qml
new file mode 100644
index 0000000000..a73e0e0e9e
--- /dev/null
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/time-picker/TimePickerMain.qml
@@ -0,0 +1,59 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+//! [file]
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls.Material
+
+ApplicationWindow {
+ id: window
+ width: 600
+ height: 600
+ visible: true
+ title: qsTr("Time Picker Example")
+
+ Material.theme: darkThemeSwitch.checked ? Material.Dark : Material.Light
+
+ // Shows the selected time and opens the dialog.
+ TimeComponentLabel {
+ id: openDialogLabel
+ width: parent.width - 80
+ anchors.centerIn: parent
+ font.pixelSize: Qt.application.font.pixelSize * 8
+ renderTypeQuality: Text.VeryHighRenderTypeQuality
+ interactive: !timePickerDialog.opened
+
+ text: Qt.formatTime(new Date(1970, 1, 1, timePickerDialog.hours, timePickerDialog.minutes), "hh:mm")
+
+ onTapped: timePickerDialog.openWithMode(TimePicker.Mode.Hours)
+ }
+
+ ColumnLayout {
+ // We always want the openDialogLabel to be centered in the window, not us.
+ // For that reason, we use anchors rather than putting the root items into a ColumnLayout.
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: openDialogLabel.bottom
+ anchors.topMargin: 24
+ spacing: 12
+
+ Switch {
+ id: is24HourSwitch
+ text: qsTr("24 Hour")
+ checked: timePickerDialog.is24Hour
+ }
+ Switch {
+ id: darkThemeSwitch
+ text: qsTr("Dark")
+ }
+ }
+
+ TimePickerDialog {
+ id: timePickerDialog
+ anchors.centerIn: parent
+ is24Hour: is24HourSwitch.checked
+
+ onTimeAccepted: print("A time was chosen - do something here!")
+ }
+}
+//! [file]
diff --git a/tests/auto/quick/doc/how-tos/how-to-qml/tst_how-to-qml.cpp b/tests/auto/quick/doc/how-tos/how-to-qml/tst_how-to-qml.cpp
index 35c010e1dd..fdc8981932 100644
--- a/tests/auto/quick/doc/how-tos/how-to-qml/tst_how-to-qml.cpp
+++ b/tests/auto/quick/doc/how-tos/how-to-qml/tst_how-to-qml.cpp
@@ -6,12 +6,25 @@
#include <QtQml/qqmlapplicationengine.h>
#include <QtQuick/qquickitem.h>
#include <QtQuick/qquickwindow.h>
+#include <QtQuick/private/qquickrepeater_p.h>
+#include <QtQuickTemplates2/private/qquickdialog_p.h>
+#include <QtQuickTemplates2/private/qquickdialogbuttonbox_p.h>
+#include <QtQuickTemplates2/private/qquicklabel_p.h>
#include <QtQuickTemplates2/private/qquicktextfield_p.h>
#include <QtQuickControlsTestUtils/private/controlstestutils_p.h>
+#include <QtQuickControlsTestUtils/private/dialogstestutils_p.h>
QT_BEGIN_NAMESPACE
+using namespace QQuickVisualTestUtils;
using namespace QQuickControlsTestUtils;
+using namespace QQuickDialogTestUtils;
+
+// Allows us to use test macros outside of test functions.
+#define RETURN_IF_FAILED(expression) \
+expression; \
+if (QTest::currentTestFailed()) \
+ return
class tst_HowToQml : public QObject
{
@@ -23,15 +36,20 @@ public:
private slots:
void init();
void activeFocusDebugging();
+ void timePicker();
+
+private:
+ QScopedPointer<QPointingDevice> touchScreen = QScopedPointer<QPointingDevice>(QTest::createTouchDevice());
};
tst_HowToQml::tst_HowToQml()
{
+ qputenv("QML_NO_TOUCH_COMPRESSION", "1");
}
void tst_HowToQml::init()
{
- QTest::failOnWarning(QRegularExpression(QStringLiteral(".?")));
+// QTest::failOnWarning(QRegularExpression(QStringLiteral(".?")));
}
void tst_HowToQml::activeFocusDebugging()
@@ -58,6 +76,283 @@ void tst_HowToQml::activeFocusDebugging()
QVERIFY(textField2->hasActiveFocus());
}
+void tst_HowToQml::timePicker()
+{
+ QQmlApplicationEngine engine;
+ engine.loadFromModule("HowToQml", "TimePickerMain");
+ QCOMPARE(engine.rootObjects().size(), 1);
+
+ auto *window = qobject_cast<QQuickWindow*>(engine.rootObjects().at(0));
+ window->show();
+ QVERIFY(QTest::qWaitForWindowExposed(window));
+
+ auto *dialog = window->findChild<QQuickDialog *>();
+ QVERIFY(dialog);
+
+ auto *timePicker = dialog->findChild<QQuickItem *>("timePicker");
+ QVERIFY(timePicker);
+
+ auto *contentContainer = timePicker->findChild<QQuickItem *>("contentContainer");
+ QVERIFY(contentContainer);
+ const int contentRadius = contentContainer->property("radius").toReal();
+
+ auto *labelRepeater = timePicker->findChild<QQuickRepeater *>();
+ QVERIFY(labelRepeater);
+
+ auto *selectionArm = timePicker->findChild<QQuickItem *>("selectionArm");
+ QVERIFY(selectionArm);
+
+ auto *selectionIndicator = selectionArm->findChild<QQuickItem *>("selectionIndicator");
+ QVERIFY(selectionIndicator);
+ const int selectionIndicatorHeight = selectionIndicator->height();
+
+ auto angleForValue = [](int value) -> int {
+ return int((value / 60.0) * 360) % 360;
+ };
+
+ // Note that is24HourValue should be true if "value" is a 24-hour value,
+ // not if the picker's is24Hour property is true.
+ auto labelCenterPosForValue = [&](int value, bool is24HourValue = false) -> QPoint {
+ if (value < 0 || value > 60)
+ return {};
+
+ const qreal angle = angleForValue(value);
+
+ QTransform transform;
+ // Translate to the center.
+ transform.translate(contentRadius, contentRadius);
+ // Rotate to the correct angle.
+ transform.rotate(angle);
+ // Go outward.
+ const int labelDistance = is24HourValue ? selectionIndicatorHeight * 1.5 : selectionIndicatorHeight * 0.5;
+ transform.translate(0, -contentRadius + labelDistance);
+
+ const auto centerPos = transform.map(QPoint(0, 0));
+ return centerPos;
+ };
+
+ enum Mode {
+ Hours,
+ Minutes
+ };
+
+ const int valuesPerLabelStep = 5;
+ const bool TwelveHour = false;
+ const bool TwentyFourHour = true;
+
+ // Checks that all the labels are in their expected positions and that they have the correct text.
+ auto verifyLabels = [&](Mode expectedMode, bool is24Hour, int callerLineNumber) {
+ // When not in 24 hour mode, there are always 12 labels, regardless of whether it's showing hours or minutes.
+ const int expectedLabelCount = expectedMode == Hours && is24Hour ? 24 : 12;
+ for (int i = 0; i < expectedLabelCount; ++i) {
+ auto *labelDelegate = labelRepeater->itemAt(i);
+ QVERIFY2(labelDelegate, qPrintable(QString::fromLatin1("Expected valid label delegate item at index %1 (caller line %2)")
+ .arg(i).arg(callerLineNumber)));
+ // Use the waiting variant of the macro because there are opacity animations.
+ // TODO: is this causing the failure on line 224?
+ QTRY_VERIFY2(qFuzzyCompare(labelDelegate->opacity(), 1.0), qPrintable(QString::fromLatin1(
+ "Expected label opacity at index %1 to be 1 but it's %2 (caller line %3)").arg(i)
+ .arg(labelDelegate->opacity()).arg(callerLineNumber)));
+
+ const int expectedValue = (i * valuesPerLabelStep) % 60;
+ const int actualValue = labelDelegate->property("value").toInt();
+ QVERIFY2(expectedValue == actualValue, qPrintable(QString::fromLatin1(
+ "Expected label's value at index %1 to be %2 but it's %3 (caller line %4)")
+ .arg(i).arg(expectedValue).arg(actualValue).arg(callerLineNumber)));
+
+ const QString expectedText = QString::number(expectedMode == Hours
+ ? (i == 0 ? 12 : (i == 12 ? 0 : i)) : i * valuesPerLabelStep);
+ // Use QTRY_VERIFY2 rather than QVERIFY2, because text changes are animated.
+ QTRY_VERIFY2(expectedText == labelDelegate->property("text").toString(), qPrintable(QString::fromLatin1(
+ "Expected label's text at index %1 to be %2 but it's %3 (caller line %4)").arg(i)
+ .arg(expectedText, labelDelegate->property("text").toString(), QString::number(callerLineNumber))));
+ }
+ };
+
+ auto verifySelectionIndicator = [&](int expectedValue, bool expect24HourValue, int callerLineNumber) {
+ const qreal actualRotation = int(selectionArm->rotation()) % 360;
+ const qreal expectedRotation = angleForValue(expectedValue);
+ QVERIFY2(qFuzzyCompare(actualRotation, expectedRotation), qPrintable(QString::fromLatin1(
+ "Expected selection arm's rotation to be %1 for expectedValue %2 but it's %3 (caller line %4)")
+ .arg(expectedRotation).arg(expectedValue).arg(actualRotation).arg(callerLineNumber)));
+
+ const QPoint expectedIndicatorCenterPos = labelCenterPosForValue(expectedValue, expect24HourValue);
+ const QPoint actualIndicatorCenterPos = selectionIndicator->mapToItem(
+ contentContainer, selectionIndicator->boundingRect().center().toPoint()).toPoint();
+ const QPoint difference = actualIndicatorCenterPos - expectedIndicatorCenterPos;
+ QVERIFY2(difference.x() <= 2 && difference.y() <= 2, qPrintable(QString::fromLatin1(
+ "Expected selection indicator's center position to be %1 (with 2 pixels of tolerance) but it's %2 (caller line %3)")
+ .arg(QDebug::toString(expectedIndicatorCenterPos), QDebug::toString(actualIndicatorCenterPos)).arg(callerLineNumber)));
+ };
+
+ auto valueForHour = [&](int hour) {
+ return (hour * valuesPerLabelStep) % 60;
+ };
+
+ // Open the picker to hours mode by clicking on the label.
+ auto *openDialogLabel = window->findChild<QQuickLabel *>(QString(), Qt::FindDirectChildrenOnly);
+ QVERIFY(openDialogLabel);
+ QCOMPARE(openDialogLabel->text(), "12:00");
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapCenterToWindow(openDialogLabel));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapCenterToWindow(openDialogLabel));
+ QTRY_COMPARE(dialog->property("opened").toBool(), true);
+ // It should show hours.
+ RETURN_IF_FAILED(verifyLabels(Hours, TwelveHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(0, TwelveHour, __LINE__));
+
+ // Select the 3rd hour.
+ const QPoint thirdHourPos = labelCenterPosForValue(valueForHour(3));
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapToWindow(contentContainer, thirdHourPos));
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(3), TwelveHour, __LINE__));
+ QCOMPARE(timePicker->property("mode").toInt(), Hours);
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapToWindow(contentContainer, thirdHourPos));
+ QCOMPARE(timePicker->property("hours").toInt(), 3);
+ QCOMPARE(timePicker->property("minutes").toInt(), 0);
+ // The dialog's values shouldn't change until the dialog has been accepted.
+ QCOMPARE(dialog->property("hours").toInt(), 12);
+ QCOMPARE(dialog->property("minutes").toInt(), 0);
+ // It should be showing minutes now.
+ QCOMPARE(timePicker->property("mode").toInt(), Minutes);
+ RETURN_IF_FAILED(verifyLabels(Minutes, TwelveHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(0, TwelveHour, __LINE__));
+ auto *minutesLabel = dialog->findChild<QQuickLabel *>("minutesLabel");
+ QVERIFY(minutesLabel);
+ QCOMPARE(minutesLabel->property("dim").toBool(), false);
+
+ // Select the 59th minute.
+ const QPoint fiftyNinthMinutePos = labelCenterPosForValue(59);
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapToWindow(contentContainer, fiftyNinthMinutePos));
+ RETURN_IF_FAILED(verifySelectionIndicator(59, TwelveHour, __LINE__));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapToWindow(contentContainer, fiftyNinthMinutePos));
+ QCOMPARE(timePicker->property("hours").toInt(), 3);
+ QCOMPARE(timePicker->property("minutes").toInt(), 59);
+ QCOMPARE(dialog->property("hours").toInt(), 12);
+ QCOMPARE(dialog->property("minutes").toInt(), 0);
+ // It shouldn't be closed until the OK or Cancel buttons are clicked.
+ QCOMPARE(dialog->property("opened").toBool(), true);
+
+ // Accept the dialog to make the changes.
+ auto *dialogButtonBox = qobject_cast<QQuickDialogButtonBox *>(dialog->footer());
+ QVERIFY(dialogButtonBox);
+ auto *okButton = findDialogButton(dialogButtonBox, "OK");
+ QTest::ignoreMessage(QtDebugMsg, "A time was chosen - do something here!");
+ QVERIFY(clickButton(okButton));
+ QTRY_COMPARE(dialog->property("visible").toBool(), false);
+ QCOMPARE(dialog->property("hours").toInt(), 3);
+ QCOMPARE(dialog->property("minutes").toInt(), 59);
+ QCOMPARE(openDialogLabel->text(), "03:59");
+
+ // Open it again.
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapCenterToWindow(openDialogLabel));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapCenterToWindow(openDialogLabel));
+ QTRY_COMPARE(dialog->property("opened"), true);
+ RETURN_IF_FAILED(verifyLabels(Hours, TwelveHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(3), TwelveHour, __LINE__));
+ // The time label should be unchanged.
+ QCOMPARE(openDialogLabel->text(), "03:59");
+
+ // Switch from hours to minutes by clicking on the minutes label.
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapCenterToWindow(minutesLabel));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapCenterToWindow(minutesLabel));
+ RETURN_IF_FAILED(verifyLabels(Minutes, TwelveHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(59, TwelveHour, __LINE__));
+
+ // Select the 1st minute.
+ const QPoint firstMinutePos = labelCenterPosForValue(1);
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapToWindow(contentContainer, firstMinutePos));
+ RETURN_IF_FAILED(verifySelectionIndicator(1, TwelveHour, __LINE__));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapToWindow(contentContainer, firstMinutePos));
+ QCOMPARE(timePicker->property("hours").toInt(), 3);
+ QCOMPARE(timePicker->property("minutes").toInt(), 1);
+ // It shouldn't be closed until the OK or Cancel buttons are clicked.
+ QCOMPARE(dialog->property("opened").toBool(), true);
+
+ // Accept the dialog to make the changes.
+ QTest::ignoreMessage(QtDebugMsg, "A time was chosen - do something here!");
+ QVERIFY(clickButton(okButton));
+ QTRY_COMPARE(dialog->property("visible").toBool(), false);
+ QCOMPARE(dialog->property("hours").toInt(), 3);
+ QCOMPARE(dialog->property("minutes").toInt(), 1);
+ QCOMPARE(openDialogLabel->text(), "03:01");
+
+ // Check that hours and minutes set programmatically on the picker and dialog are respected.
+ QVERIFY(dialog->setProperty("hours", QVariant::fromValue(7)));
+ QVERIFY(dialog->setProperty("minutes", QVariant::fromValue(8)));
+ QCOMPARE(openDialogLabel->text(), "07:08");
+ // Open the picker to hours mode by clicking on the label.
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapCenterToWindow(openDialogLabel));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapCenterToWindow(openDialogLabel));
+ QTRY_COMPARE(dialog->property("opened").toBool(), true);
+ RETURN_IF_FAILED(verifyLabels(Hours, TwelveHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(7), TwelveHour, __LINE__));
+ QCOMPARE(timePicker->property("hours").toInt(), 7);
+ QCOMPARE(timePicker->property("minutes").toInt(), 8);
+
+ // Check that cancelling the dialog cancels any changes.
+ // Select the fourth hour.
+ const QPoint fourthHourPos = labelCenterPosForValue(20);
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapToWindow(contentContainer, fourthHourPos));
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(4), TwelveHour, __LINE__));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapToWindow(contentContainer, fourthHourPos));
+ QCOMPARE(timePicker->property("hours").toInt(), 4);
+ QCOMPARE(timePicker->property("minutes").toInt(), 8);
+ auto *cancelButton = findDialogButton(dialogButtonBox, "Cancel");
+ QVERIFY(clickButton(cancelButton));
+ QTRY_COMPARE(dialog->property("visible").toBool(), false);
+ QCOMPARE(dialog->property("hours").toInt(), 7);
+ QCOMPARE(dialog->property("minutes").toInt(), 8);
+
+ // Test that the 24 hour mode works.
+ QVERIFY(dialog->setProperty("is24Hour", QVariant::fromValue(true)));
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapCenterToWindow(openDialogLabel));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapCenterToWindow(openDialogLabel));
+ QTRY_COMPARE(dialog->property("opened").toBool(), true);
+ QCOMPARE(timePicker->property("mode").toInt(), Hours);
+ RETURN_IF_FAILED(verifyLabels(Hours, TwentyFourHour, __LINE__));
+ // TwelveHour because the selected value (7) is on the 12 hour "ring".
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(7), TwelveHour, __LINE__));
+ // It should still show the old time.
+ QCOMPARE(timePicker->property("hours").toInt(), 7);
+ QCOMPARE(timePicker->property("minutes").toInt(), 8);
+ QCOMPARE(dialog->property("hours").toInt(), 7);
+ QCOMPARE(dialog->property("minutes").toInt(), 8);
+
+ // Select the 23rd hour.
+ const QPoint twentyThirdHourPos = labelCenterPosForValue(valueForHour(11), TwentyFourHour);
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapToWindow(contentContainer, twentyThirdHourPos));
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(23), TwentyFourHour, __LINE__));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapToWindow(contentContainer, twentyThirdHourPos));
+ QCOMPARE(timePicker->property("hours").toInt(), 23);
+ QCOMPARE(timePicker->property("minutes").toInt(), 8);
+ QCOMPARE(dialog->property("hours").toInt(), 7);
+ QCOMPARE(dialog->property("minutes").toInt(), 8);
+ RETURN_IF_FAILED(verifyLabels(Minutes, TwentyFourHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(8, TwelveHour, __LINE__));
+
+ // Select the 20th minute.
+ const QPoint twentiethMinutePos = labelCenterPosForValue(20);
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapToWindow(contentContainer, twentiethMinutePos));
+ RETURN_IF_FAILED(verifySelectionIndicator(20, TwelveHour, __LINE__));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapToWindow(contentContainer, twentiethMinutePos));
+ QCOMPARE(timePicker->property("hours").toInt(), 23);
+ QCOMPARE(timePicker->property("minutes").toInt(), 20);
+
+ // Go back to hours and make sure that the selection indicator is correct.
+ auto *hoursLabel = dialog->findChild<QQuickLabel *>("hoursLabel");
+ QVERIFY(hoursLabel);
+ QTest::touchEvent(window, touchScreen.data()).press(0, mapCenterToWindow(hoursLabel));
+ QTest::touchEvent(window, touchScreen.data()).release(0, mapCenterToWindow(hoursLabel));
+ RETURN_IF_FAILED(verifyLabels(Hours, TwentyFourHour, __LINE__));
+ RETURN_IF_FAILED(verifySelectionIndicator(valueForHour(23), TwentyFourHour, __LINE__));
+
+ // Accept.
+ QTest::ignoreMessage(QtDebugMsg, "A time was chosen - do something here!");
+ QVERIFY(clickButton(okButton));
+ QTRY_COMPARE(dialog->property("visible").toBool(), false);
+ QCOMPARE(dialog->property("hours").toInt(), 23);
+ QCOMPARE(dialog->property("minutes").toInt(), 20);
+}
+
QT_END_NAMESPACE
QTEST_MAIN(tst_HowToQml)