From edef1d21fb59b62ae8b684b375b046fc483dcd48 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:22:47 +1000 Subject: internal: better loading indicator Abstract circular progress into own component --- components/controls/CircularProgress.qml | 72 +++++++++++ components/controls/StyledBusyIndicator.qml | 190 ++++++++++++++++++---------- 2 files changed, 195 insertions(+), 67 deletions(-) create mode 100644 components/controls/CircularProgress.qml (limited to 'components') diff --git a/components/controls/CircularProgress.qml b/components/controls/CircularProgress.qml new file mode 100644 index 0000000..aa0de5c --- /dev/null +++ b/components/controls/CircularProgress.qml @@ -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 + } + } + } +} diff --git a/components/controls/StyledBusyIndicator.qml b/components/controls/StyledBusyIndicator.qml index 060870f..e54aafb 100644 --- a/components/controls/StyledBusyIndicator.qml +++ b/components/controls/StyledBusyIndicator.qml @@ -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 + } + + Updater { + id: updater + } - preferredRendererType: Shape.CurveRenderer - asynchronous: true + NumberAnimation { + running: root.animState !== "stopped" + loops: Animation.Infinite + target: updater + property: "progress" + from: 0 + to: 1 + duration: updater.duration + } - 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 === "completing" + target: updater + property: "completeEndProgress" + from: 0 + to: 1 + duration: updater.completeEndDuration + onFinished: { + if (root.animState === "completing") + root.animState = "stopped"; } + } - 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 - } + 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 expandDelay: [0, 1350, 2700, 4050].map(d => d * Appearance.anim.durations.scale) + readonly property list 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; - Behavior on strokeColor { - ColorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard - } + 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.fgColour - fillColor: "transparent" - capStyle: ShapePath.RoundCap - - PathAngleArc { - centerX: shape.width / 2 - centerY: shape.height / 2 - radiusX: root.implicitWidth / 2 - radiusY: root.implicitHeight / 2 - startAngle: -sweepAngle / 2 - sweepAngle: 60 - } + function getFractionInRange(currentTime: real, delay: int, duration: int): real { + if (currentTime < delay) + return 0; + if (currentTime > delay + duration) + return 1; + return (currentTime - delay) / duration; + } - PathAngleArc { - centerX: shape.width / 2 - centerY: shape.height / 2 - radiusX: root.implicitWidth / 2 - radiusY: root.implicitHeight / 2 - startAngle: 180 - sweepAngle / 2 - sweepAngle: 60 - } + function lerp(a: real, b: real, t: real): real { + return a + (b - a) * t; + } - Behavior on strokeColor { - ColorAnimation { - duration: Appearance.anim.durations.normal - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.standard - } - } + 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); } } } -- cgit v1.2.3-freya