internal: better loading indicator
Abstract circular progress into own component
This commit is contained in:
parent
3e21e627e0
commit
edef1d21fb
6 changed files with 213 additions and 159 deletions
72
components/controls/CircularProgress.qml
Normal file
72
components/controls/CircularProgress.qml
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import qs.services
|
||||||
|
import qs.config
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Shapes
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property real value
|
||||||
|
property int startAngle: -90
|
||||||
|
property int strokeWidth: Appearance.padding.smaller
|
||||||
|
property int padding: 0
|
||||||
|
property int spacing: Appearance.spacing.small
|
||||||
|
property color fgColour: Colours.palette.m3primary
|
||||||
|
property color bgColour: Colours.palette.m3secondaryContainer
|
||||||
|
|
||||||
|
readonly property real size: Math.min(width, height)
|
||||||
|
readonly property real arcRadius: (size - padding - strokeWidth) / 2
|
||||||
|
readonly property real vValue: value || 1 / 360
|
||||||
|
readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI)
|
||||||
|
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
asynchronous: true
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
fillColor: "transparent"
|
||||||
|
strokeColor: root.bgColour
|
||||||
|
strokeWidth: root.strokeWidth
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
|
||||||
|
PathAngleArc {
|
||||||
|
startAngle: root.startAngle + 360 * root.vValue + root.gapAngle
|
||||||
|
sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2)
|
||||||
|
radiusX: root.arcRadius
|
||||||
|
radiusY: root.arcRadius
|
||||||
|
centerX: root.size / 2
|
||||||
|
centerY: root.size / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on strokeColor {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Appearance.anim.durations.large
|
||||||
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Appearance.anim.curves.standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
fillColor: "transparent"
|
||||||
|
strokeColor: root.fgColour
|
||||||
|
strokeWidth: root.strokeWidth
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
|
||||||
|
PathAngleArc {
|
||||||
|
startAngle: root.startAngle
|
||||||
|
sweepAngle: 360 * root.vValue
|
||||||
|
radiusX: root.arcRadius
|
||||||
|
radiusY: root.arcRadius
|
||||||
|
centerX: root.size / 2
|
||||||
|
centerY: root.size / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on strokeColor {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Appearance.anim.durations.large
|
||||||
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Appearance.anim.curves.standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,90 +1,146 @@
|
||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import qs.services
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Shapes
|
|
||||||
|
|
||||||
BusyIndicator {
|
BusyIndicator {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property color fgColour: Colours.palette.m3onPrimaryContainer
|
property real implicitSize: Appearance.font.size.normal * 3
|
||||||
property color bgColour: Colours.palette.m3primaryContainer
|
property real strokeWidth: Appearance.padding.small
|
||||||
|
property real internalStrokeWidth: strokeWidth
|
||||||
|
property string animState
|
||||||
|
|
||||||
|
padding: 0
|
||||||
|
implicitWidth: implicitSize
|
||||||
|
implicitHeight: implicitSize
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (running) {
|
||||||
|
updater.completeEndProgress = 0;
|
||||||
|
animState = "running";
|
||||||
|
} else {
|
||||||
|
if (animState == "running")
|
||||||
|
animState = "completing";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
states: State {
|
||||||
|
name: "stopped"
|
||||||
|
when: !root.running
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
root.opacity: 0
|
||||||
|
root.internalStrokeWidth: root.strokeWidth / 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitions: Transition {
|
||||||
|
NumberAnimation {
|
||||||
|
properties: "opacity,internalStrokeWidth"
|
||||||
|
duration: updater.completeEndDuration
|
||||||
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Appearance.anim.curves.standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
background: null
|
background: null
|
||||||
|
|
||||||
contentItem: Shape {
|
contentItem: CircularProgress {
|
||||||
id: shape
|
anchors.fill: parent
|
||||||
|
strokeWidth: root.internalStrokeWidth
|
||||||
|
padding: root.padding
|
||||||
|
startAngle: updater.startFraction * 360
|
||||||
|
value: updater.endFraction - updater.startFraction
|
||||||
|
}
|
||||||
|
|
||||||
preferredRendererType: Shape.CurveRenderer
|
Updater {
|
||||||
asynchronous: true
|
id: updater
|
||||||
|
}
|
||||||
|
|
||||||
RotationAnimator on rotation {
|
NumberAnimation {
|
||||||
from: 0
|
running: root.animState !== "stopped"
|
||||||
to: 180
|
|
||||||
running: root.visible && root.running
|
|
||||||
loops: Animation.Infinite
|
loops: Animation.Infinite
|
||||||
duration: Appearance.anim.durations.extraLarge
|
target: updater
|
||||||
easing.type: Easing.Linear
|
property: "progress"
|
||||||
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
|
from: 0
|
||||||
|
to: 1
|
||||||
|
duration: updater.duration
|
||||||
}
|
}
|
||||||
|
|
||||||
ShapePath {
|
NumberAnimation {
|
||||||
strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18
|
running: root.animState === "completing"
|
||||||
strokeColor: root.bgColour
|
target: updater
|
||||||
fillColor: "transparent"
|
property: "completeEndProgress"
|
||||||
capStyle: ShapePath.RoundCap
|
from: 0
|
||||||
|
to: 1
|
||||||
PathAngleArc {
|
duration: updater.completeEndDuration
|
||||||
centerX: shape.width / 2
|
onFinished: {
|
||||||
centerY: shape.height / 2
|
if (root.animState === "completing")
|
||||||
radiusX: root.implicitWidth / 2
|
root.animState = "stopped";
|
||||||
radiusY: root.implicitHeight / 2
|
|
||||||
startAngle: 0
|
|
||||||
sweepAngle: 360
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on strokeColor {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Appearance.anim.durations.normal
|
|
||||||
easing.type: Easing.BezierSpline
|
|
||||||
easing.bezierCurve: Appearance.anim.curves.standard
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ShapePath {
|
component Updater: QtObject {
|
||||||
strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18
|
readonly property int duration: 5400 * Appearance.anim.durations.scale
|
||||||
strokeColor: root.fgColour
|
readonly property int expandDuration: 667 * Appearance.anim.durations.scale
|
||||||
fillColor: "transparent"
|
readonly property int collapseDuration: 667 * Appearance.anim.durations.scale
|
||||||
capStyle: ShapePath.RoundCap
|
readonly property int completeEndDuration: 333 * Appearance.anim.durations.scale
|
||||||
|
readonly property int tailDegOffset: -20
|
||||||
|
readonly property int extraDegPerCycle: 250
|
||||||
|
readonly property int constantRotDeg: 1520
|
||||||
|
readonly property list<int> expandDelay: [0, 1350, 2700, 4050].map(d => d * Appearance.anim.durations.scale)
|
||||||
|
readonly property list<int> collapseDelay: [667, 2017, 3367, 4717].map(d => d * Appearance.anim.durations.scale)
|
||||||
|
|
||||||
PathAngleArc {
|
property real progress: 0
|
||||||
centerX: shape.width / 2
|
property real startFraction: 0
|
||||||
centerY: shape.height / 2
|
property real endFraction: 0
|
||||||
radiusX: root.implicitWidth / 2
|
property real rotation: 0
|
||||||
radiusY: root.implicitHeight / 2
|
property real completeEndProgress: 0
|
||||||
startAngle: -sweepAngle / 2
|
|
||||||
sweepAngle: 60
|
onProgressChanged: update(progress)
|
||||||
|
|
||||||
|
function update(p: real): void {
|
||||||
|
const playtime = p * duration;
|
||||||
|
let startDeg = constantRotDeg * p + tailDegOffset;
|
||||||
|
let endDeg = constantRotDeg * p;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const expandFraction = getFractionInRange(playtime, expandDelay[i], expandDuration);
|
||||||
|
endDeg += fastOutSlowIn(expandFraction) * extraDegPerCycle;
|
||||||
|
|
||||||
|
const collapseFraction = getFractionInRange(playtime, collapseDelay[i], collapseDuration);
|
||||||
|
startDeg += fastOutSlowIn(collapseFraction) * extraDegPerCycle;
|
||||||
}
|
}
|
||||||
|
|
||||||
PathAngleArc {
|
// Gap closing
|
||||||
centerX: shape.width / 2
|
startDeg += (endDeg - startDeg) * completeEndProgress;
|
||||||
centerY: shape.height / 2
|
|
||||||
radiusX: root.implicitWidth / 2
|
startFraction = startDeg / 360;
|
||||||
radiusY: root.implicitHeight / 2
|
endFraction = endDeg / 360;
|
||||||
startAngle: 180 - sweepAngle / 2
|
|
||||||
sweepAngle: 60
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Behavior on strokeColor {
|
function getFractionInRange(currentTime: real, delay: int, duration: int): real {
|
||||||
ColorAnimation {
|
if (currentTime < delay)
|
||||||
duration: Appearance.anim.durations.normal
|
return 0;
|
||||||
easing.type: Easing.BezierSpline
|
if (currentTime > delay + duration)
|
||||||
easing.bezierCurve: Appearance.anim.curves.standard
|
return 1;
|
||||||
}
|
return (currentTime - delay) / duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lerp(a: real, b: real, t: real): real {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubic(a: real, b: real, c: real, d: real, t: real): real {
|
||||||
|
return ((1 - t) ** 3) * a + 3 * ((1 - t) ** 2) * t * b + 3 * (1 - t) * (t ** 2) * c + (t ** 3) * d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubicBezier(p1x: real, p1y: real, p2x: real, p2y: real, t: real): real {
|
||||||
|
return cubic(0, p1y, p2y, 1, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fastOutSlowIn(t: real): real {
|
||||||
|
return cubicBezier(0.4, 0.0, 0.2, 1.0, t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,17 +111,8 @@ ColumnLayout {
|
||||||
color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0)
|
color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0)
|
||||||
|
|
||||||
StyledBusyIndicator {
|
StyledBusyIndicator {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
running: device.loading
|
||||||
implicitWidth: implicitHeight
|
|
||||||
implicitHeight: connectIcon.implicitHeight
|
|
||||||
|
|
||||||
running: opacity > 0
|
|
||||||
opacity: device.loading ? 1 : 0
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
Anim {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
|
|
|
||||||
|
|
@ -105,17 +105,8 @@ ColumnLayout {
|
||||||
color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0)
|
color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0)
|
||||||
|
|
||||||
StyledBusyIndicator {
|
StyledBusyIndicator {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
running: networkItem.loading
|
||||||
implicitWidth: implicitHeight
|
|
||||||
implicitHeight: connectIcon.implicitHeight
|
|
||||||
|
|
||||||
running: opacity > 0
|
|
||||||
opacity: networkItem.loading ? 1 : 0
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
Anim {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
|
|
|
||||||
|
|
@ -237,23 +237,14 @@ ColumnLayout {
|
||||||
id: connectBtn
|
id: connectBtn
|
||||||
|
|
||||||
implicitWidth: implicitHeight
|
implicitWidth: implicitHeight
|
||||||
implicitHeight: connectIcon.implicitHeight + Appearance.padding.small * 2
|
implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
|
||||||
|
|
||||||
radius: Appearance.rounding.full
|
radius: Appearance.rounding.full
|
||||||
color: Qt.alpha(Colours.palette.m3primaryContainer, device.connected ? 1 : 0)
|
color: Qt.alpha(Colours.palette.m3primaryContainer, device.connected ? 1 : 0)
|
||||||
|
|
||||||
StyledBusyIndicator {
|
StyledBusyIndicator {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
running: device.loading
|
||||||
implicitWidth: implicitHeight
|
|
||||||
implicitHeight: connectIcon.implicitHeight
|
|
||||||
|
|
||||||
running: opacity > 0
|
|
||||||
opacity: device.loading ? 1 : 0
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
Anim {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import qs.components
|
import qs.components
|
||||||
|
import qs.components.controls
|
||||||
import qs.components.misc
|
import qs.components.misc
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.config
|
import qs.config
|
||||||
|
|
@ -57,69 +58,21 @@ GridLayout {
|
||||||
required property real value
|
required property real value
|
||||||
required property color colour
|
required property color colour
|
||||||
|
|
||||||
readonly property int thickness: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal
|
|
||||||
readonly property real arcRadius: (width - Appearance.padding.large * 3 - thickness) / 2
|
|
||||||
readonly property real vValue: value || 1 / 360
|
|
||||||
readonly property real gapAngle: ((Appearance.spacing.small + thickness) / (arcRadius || 1)) * (180 / Math.PI)
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: width
|
implicitHeight: width
|
||||||
|
|
||||||
color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
|
color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
|
||||||
radius: Appearance.rounding.large
|
radius: Appearance.rounding.large
|
||||||
|
|
||||||
Shape {
|
CircularProgress {
|
||||||
|
id: circ
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
preferredRendererType: Shape.CurveRenderer
|
value: res.value
|
||||||
asynchronous: true
|
padding: Appearance.padding.large * 3
|
||||||
|
fgColour: res.colour
|
||||||
ShapePath {
|
bgColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 3)
|
||||||
fillColor: "transparent"
|
strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal
|
||||||
strokeColor: Colours.layer(Colours.palette.m3surfaceContainerHighest, 3)
|
|
||||||
strokeWidth: res.thickness
|
|
||||||
capStyle: ShapePath.RoundCap
|
|
||||||
|
|
||||||
PathAngleArc {
|
|
||||||
startAngle: -90 + 360 * res.vValue + res.gapAngle
|
|
||||||
sweepAngle: 360 * (1 - res.vValue) - res.gapAngle * 2
|
|
||||||
radiusX: res.arcRadius
|
|
||||||
radiusY: res.arcRadius
|
|
||||||
centerX: res.width / 2
|
|
||||||
centerY: res.width / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on strokeColor {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Appearance.anim.durations.large
|
|
||||||
easing.type: Easing.BezierSpline
|
|
||||||
easing.bezierCurve: Appearance.anim.curves.standard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ShapePath {
|
|
||||||
fillColor: "transparent"
|
|
||||||
strokeColor: res.colour
|
|
||||||
strokeWidth: res.thickness
|
|
||||||
capStyle: ShapePath.RoundCap
|
|
||||||
|
|
||||||
PathAngleArc {
|
|
||||||
startAngle: -90
|
|
||||||
sweepAngle: 360 * res.vValue
|
|
||||||
radiusX: res.arcRadius
|
|
||||||
radiusY: res.arcRadius
|
|
||||||
centerX: res.width / 2
|
|
||||||
centerY: res.width / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on strokeColor {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Appearance.anim.durations.large
|
|
||||||
easing.type: Easing.BezierSpline
|
|
||||||
easing.bezierCurve: Appearance.anim.curves.standard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialIcon {
|
MaterialIcon {
|
||||||
|
|
@ -128,7 +81,7 @@ GridLayout {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: res.icon
|
text: res.icon
|
||||||
color: res.colour
|
color: res.colour
|
||||||
font.pointSize: (res.arcRadius * 0.7) || 1
|
font.pointSize: (circ.arcRadius * 0.7) || 1
|
||||||
font.weight: 600
|
font.weight: 600
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue