internal: better loading indicator

Abstract circular progress into own component
This commit is contained in:
2 * r + 2 * t 2025-08-14 23:22:47 +10:00
parent 3e21e627e0
commit edef1d21fb
6 changed files with 213 additions and 159 deletions

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

View file

@ -1,90 +1,146 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.config
import QtQuick
import QtQuick.Controls
import QtQuick.Shapes
BusyIndicator {
id: root
property color fgColour: Colours.palette.m3onPrimaryContainer
property color bgColour: Colours.palette.m3primaryContainer
property real implicitSize: Appearance.font.size.normal * 3
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
contentItem: Shape {
id: shape
contentItem: CircularProgress {
anchors.fill: parent
strokeWidth: root.internalStrokeWidth
padding: root.padding
startAngle: updater.startFraction * 360
value: updater.endFraction - updater.startFraction
}
preferredRendererType: Shape.CurveRenderer
asynchronous: true
Updater {
id: updater
}
RotationAnimator on rotation {
from: 0
to: 180
running: root.visible && root.running
loops: Animation.Infinite
duration: Appearance.anim.durations.extraLarge
easing.type: Easing.Linear
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
NumberAnimation {
running: root.animState !== "stopped"
loops: Animation.Infinite
target: updater
property: "progress"
from: 0
to: 1
duration: updater.duration
}
NumberAnimation {
running: root.animState === "completing"
target: updater
property: "completeEndProgress"
from: 0
to: 1
duration: updater.completeEndDuration
onFinished: {
if (root.animState === "completing")
root.animState = "stopped";
}
}
component Updater: QtObject {
readonly property int duration: 5400 * Appearance.anim.durations.scale
readonly property int expandDuration: 667 * Appearance.anim.durations.scale
readonly property int collapseDuration: 667 * Appearance.anim.durations.scale
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)
property real progress: 0
property real startFraction: 0
property real endFraction: 0
property real rotation: 0
property real completeEndProgress: 0
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;
}
// Gap closing
startDeg += (endDeg - startDeg) * completeEndProgress;
startFraction = startDeg / 360;
endFraction = endDeg / 360;
}
ShapePath {
strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18
strokeColor: root.bgColour
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: shape.width / 2
centerY: shape.height / 2
radiusX: root.implicitWidth / 2
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
}
}
function getFractionInRange(currentTime: real, delay: int, duration: int): real {
if (currentTime < delay)
return 0;
if (currentTime > delay + duration)
return 1;
return (currentTime - delay) / duration;
}
ShapePath {
strokeWidth: Math.min(root.implicitWidth, root.implicitHeight) * 0.18
strokeColor: root.fgColour
fillColor: "transparent"
capStyle: ShapePath.RoundCap
function lerp(a: real, b: real, t: real): real {
return a + (b - a) * t;
}
PathAngleArc {
centerX: shape.width / 2
centerY: shape.height / 2
radiusX: root.implicitWidth / 2
radiusY: root.implicitHeight / 2
startAngle: -sweepAngle / 2
sweepAngle: 60
}
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;
}
PathAngleArc {
centerX: shape.width / 2
centerY: shape.height / 2
radiusX: root.implicitWidth / 2
radiusY: root.implicitHeight / 2
startAngle: 180 - sweepAngle / 2
sweepAngle: 60
}
function cubicBezier(p1x: real, p1y: real, p2x: real, p2y: real, t: real): real {
return cubic(0, p1y, p2y, 1, t);
}
Behavior on strokeColor {
ColorAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.standard
}
}
function fastOutSlowIn(t: real): real {
return cubicBezier(0.4, 0.0, 0.2, 1.0, t);
}
}
}

View file

@ -111,17 +111,8 @@ ColumnLayout {
color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0)
StyledBusyIndicator {
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: connectIcon.implicitHeight
running: opacity > 0
opacity: device.loading ? 1 : 0
Behavior on opacity {
Anim {}
}
anchors.fill: parent
running: device.loading
}
StateLayer {

View file

@ -105,17 +105,8 @@ ColumnLayout {
color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0)
StyledBusyIndicator {
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: connectIcon.implicitHeight
running: opacity > 0
opacity: networkItem.loading ? 1 : 0
Behavior on opacity {
Anim {}
}
anchors.fill: parent
running: networkItem.loading
}
StateLayer {

View file

@ -237,23 +237,14 @@ ColumnLayout {
id: connectBtn
implicitWidth: implicitHeight
implicitHeight: connectIcon.implicitHeight + Appearance.padding.small * 2
implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
radius: Appearance.rounding.full
color: Qt.alpha(Colours.palette.m3primaryContainer, device.connected ? 1 : 0)
StyledBusyIndicator {
anchors.centerIn: parent
implicitWidth: implicitHeight
implicitHeight: connectIcon.implicitHeight
running: opacity > 0
opacity: device.loading ? 1 : 0
Behavior on opacity {
Anim {}
}
anchors.fill: parent
running: device.loading
}
StateLayer {

View file

@ -1,4 +1,5 @@
import qs.components
import qs.components.controls
import qs.components.misc
import qs.services
import qs.config
@ -57,69 +58,21 @@ GridLayout {
required property real value
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
implicitHeight: width
color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
radius: Appearance.rounding.large
Shape {
CircularProgress {
id: circ
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
asynchronous: true
ShapePath {
fillColor: "transparent"
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
}
}
}
value: res.value
padding: Appearance.padding.large * 3
fgColour: res.colour
bgColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 3)
strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal
}
MaterialIcon {
@ -128,7 +81,7 @@ GridLayout {
anchors.centerIn: parent
text: res.icon
color: res.colour
font.pointSize: (res.arcRadius * 0.7) || 1
font.pointSize: (circ.arcRadius * 0.7) || 1
font.weight: 600
}