Files
evcharger-app/TimePicker.qml
T

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
}
]
}
}
}
}