diff --git a/ui/components/animation.slint b/ui/components/animation.slint new file mode 100644 index 00000000..d6b8148a --- /dev/null +++ b/ui/components/animation.slint @@ -0,0 +1,72 @@ +/** + a loading animation intended for replacing build-in component Spinner + + it repeat a same animate forever. + the arc in animation controlled by head and tail, assert(head>=tail). + head and tail follow a same easing, but not in same time by using different offset. + the `easing` function will compress given easing function according to offset. + given easing function require `float(float)` function signature, input 0~1, output 0~1 +*/ +import { Token } from "../global.slint"; +component LoadingAnimation inherits Path { + // available option + // the time of a repetition + in property circle-time: 1000ms; + // the min distance between head and tail (percentage of a circle) + in property distance: 0.1; + // width + stroke-width: 3px; + // color + stroke: Token.color.on-primary-container; + // implement detail + viewbox-height: 1; + viewbox-width: 1; + private property radius: 0.5; + // 0~1, means the progress of a repetition + private property progress: Math.mod(animation-tick() / 1ms,circle-time / 1ms) / (circle-time / 1ms); + private property tail: easing(progress, distance) - distance / 2; + private property head: easing(progress, - distance) + distance / 2; + private property <{x: float, y: float}> tail-point: polar-to-cartesian(radius, tail * 1turn); + private property <{x: float, y: float}> head-point: polar-to-cartesian(radius, head * 1turn); + MoveTo { + x: tail-point.x; + y: tail-point.y; + } + + ArcTo { + sweep: true; + large-arc: head - tail > 0.5; + radius-x: radius; + radius-y: radius; + x: head-point.x; + y: head-point.y; + } + + pure function easing(x: float, offset: float) -> float { + if (offset > 0) { + return x > offset ? _inner-easing((x - offset) / (1 - offset)) : 0; + } else if (offset < 0) { + return x < 1 + offset ? _inner-easing(x / (1 + offset)) : 1; + } else { + return _inner-easing(x); + } + } + // convenient easing change + pure function _inner-easing(x: float) -> float { + return ease-in-out-quint(x); + } + // easing function from https://easings.net/ + pure function ease-in-out-sine(x: float) -> float { + return -(Math.cos(3.1415926 * x * 1rad) - 1) / 2; + } + pure function ease-in-out-quint(x: float) -> float { + return x < 0.5 ? 16 * x * x * x * x * x : 1 - Math.pow(-2 * x + 2, 5) / 2; + } + // polar to cartesian coordinate system + pure function polar-to-cartesian(r: float, theta: angle) -> {x: float, y: float} { + return { + x: 0.5 + r * Math.cos(theta - 90deg), + y: 0.5 + r * Math.sin(theta - 90deg), + }; + } +} diff --git a/ui/components/button.slint b/ui/components/button.slint new file mode 100644 index 00000000..9137a4ad --- /dev/null +++ b/ui/components/button.slint @@ -0,0 +1,83 @@ +import { StateLayer } from "state_layer.slint"; +import { Token } from "../global.slint"; +import { LoadingAnimation } from "animation.slint"; + +export component LoadingButton inherits Rectangle { + // available option + in property on-surface: Token.color.on-surface; + in property surface: Token.color.surface-container; + in property is-loading: false; + in property content: "No Content"; + in property font-size: Token.font.body.large.size; + height: 40px; + callback clicked <=> area.clicked; + // implement detail + private property max-radius: 20px; + private property icon-size: 18px; + private property default-padding: 24px; + private property icon-padding: 16px; + private property default-spacing: 8px; + clip: true; + background: surface; + border-radius: self.height / 2 > max-radius ? max-radius : self.height / 2; + StateLayer { + background: on-surface; + is-hover: area.has-hover; + is-press: area.pressed; + } + + _spinner := LoadingAnimation { + x: icon-padding; + width: icon-size; + stroke: on-surface; + } + + _text := Text { + x: (root.width - self.width) / 2; + text: content; + font-size: font-size; + vertical-alignment: center; + horizontal-alignment: center; + } + + area := TouchArea { + mouse-cursor: pointer; + } + + states [ + // width = icon-padding + icon-size + default-spacing + _text.width + default-padding + loading when is-loading: { + width: icon-padding + icon-size + default-spacing + _text.width + default-padding; + _text.x: icon-padding + icon-size + default-spacing; + _spinner.opacity: 1; + in { + animate width, _text.x, _spinner.opacity { + duration: 200ms; + easing: ease-in-out-quint; + } + } + out { + animate width, _text.x, _spinner.opacity { + duration: 200ms; + easing: ease-in-out-quint; + } + } + } + // width = default-padding + _text.width + default-padding + normal when !is-loading: { + width: default-padding + _text.width + default-padding; + _text.x: default-padding; + _spinner.opacity: 0; + } + ] +} + +// quick preview +component Example { + LoadingButton { + content: "click me"; + clicked => { + self.is-loading = !self.is-loading; + } + } +} diff --git a/ui/components/index.slint b/ui/components/index.slint index 362ceb8b..48db3c64 100644 --- a/ui/components/index.slint +++ b/ui/components/index.slint @@ -1,4 +1,4 @@ -export { +export { Page, Overlay, Empty @@ -8,6 +8,14 @@ export { StateLayer } from "./state_layer.slint"; -export { +export { Toast -} from "./toast.slint"; \ No newline at end of file +} from "./toast.slint"; + +export { + LoadingAnimation +} from "./animation.slint"; + +export { + LoadingButton +} from "./button.slint"; \ No newline at end of file