317 lines
12 KiB
QML
317 lines
12 KiB
QML
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
|
|
|
|
SequentialAnimation {
|
|
NumberAnimation {
|
|
target: contentContainer
|
|
property: "labelOpacity"
|
|
from: 1
|
|
to: 0
|
|
duration: 100
|
|
}
|
|
|
|
ScriptAction {
|
|
script: root.__effectiveMode = root.mode
|
|
}
|
|
|
|
NumberAnimation {
|
|
target: contentContainer
|
|
property: "labelOpacity"
|
|
from: 0
|
|
to: 1
|
|
duration: 100
|
|
}
|
|
}
|
|
},
|
|
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
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|