diff --git a/CHANGELOG.md b/CHANGELOG.md index 02f0a9e58e..eedaa9f1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ This means that druid no longer requires cairo on macOS and uses Core Graphics i - `Button::dynamic` constructor. ([#963] by [@totsteps]) - `set_menu` method on `UpdateCtx` and `LifeCycleCtx` ([#970] by [@cmyr]) - Standardize and expose more methods on more contexts ([#972] by [@cmyr]) +- `Spinner` widget to represent loading states. ([#1003] by [@futurepaul]) ### Changed @@ -254,6 +255,7 @@ This means that druid no longer requires cairo on macOS and uses Core Graphics i [#996]: https://github.com/xi-editor/druid/pull/996 [#997]: https://github.com/xi-editor/druid/pull/997 [#1001]: https://github.com/xi-editor/druid/pull/1001 +[#1003]: https://github.com/xi-editor/druid/pull/1003 ## [0.5.0] - 2020-04-01 diff --git a/druid/examples/blocking_function.rs b/druid/examples/blocking_function.rs index 2700de6fd4..eb47b1959c 100644 --- a/druid/examples/blocking_function.rs +++ b/druid/examples/blocking_function.rs @@ -21,7 +21,7 @@ use druid::{ Selector, Target, Widget, WidgetExt, WindowDesc, }; -use druid::widget::{Button, Either, Flex, Label}; +use druid::widget::{Button, Either, Flex, Label, Spinner}; const START_SLOW_FUNCTION: Selector = Selector::new("start_slow_function"); @@ -80,9 +80,9 @@ fn ui_builder() -> impl Widget { ctx.submit_command(cmd, None); }) .padding(5.0); - let button_placeholder = Label::new(LocalizedString::new("Processing...")) - .padding(5.0) - .center(); + let button_placeholder = Flex::column() + .with_child(Label::new(LocalizedString::new("Processing...")).padding(5.0)) + .with_child(Spinner::new()); let text = LocalizedString::new("hello-counter") .with_arg("count", |data: &AppState, _env| (data.value).into()); diff --git a/druid/src/widget/mod.rs b/druid/src/widget/mod.rs index ce5c9aa7ec..0de4e3eab5 100644 --- a/druid/src/widget/mod.rs +++ b/druid/src/widget/mod.rs @@ -39,6 +39,7 @@ mod radio; mod scroll; mod sized_box; mod slider; +mod spinner; mod split; mod stepper; #[cfg(feature = "svg")] @@ -75,6 +76,7 @@ pub use radio::{Radio, RadioGroup}; pub use scroll::Scroll; pub use sized_box::SizedBox; pub use slider::Slider; +pub use spinner::Spinner; pub use split::Split; pub use stepper::Stepper; #[cfg(feature = "svg")] diff --git a/druid/src/widget/spinner.rs b/druid/src/widget/spinner.rs new file mode 100644 index 0000000000..67b6d5e949 --- /dev/null +++ b/druid/src/widget/spinner.rs @@ -0,0 +1,130 @@ +// Copyright 2020 The xi-editor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! An animated spinner widget. + +use std::f64::consts::PI; + +use druid::kurbo::Line; +use druid::widget::prelude::*; +use druid::{theme, Color, Data, KeyOrValue, Point, Vec2}; + +/// An animated spinner widget for showing a loading state. +/// +/// To customize the spinner's size, you can place it inside a [`SizedBox`] +/// that has a fixed width and height. +/// +/// [`SizedBox`]: struct.SizedBox.html +pub struct Spinner { + t: f64, + color: KeyOrValue, +} + +impl Spinner { + /// Create a spinner widget + pub fn new() -> Spinner { + Spinner::default() + } + + /// Builder-style method for setting the spinner's color. + /// + /// The argument can be either a `Color` or a [`Key`]. + /// + /// [`Key`]: ../struct.Key.html + pub fn with_color(mut self, color: impl Into>) -> Self { + self.color = color.into(); + self + } + + /// Set the spinner's color. + /// + /// The argument can be either a `Color` or a [`Key`]. + /// + /// [`Key`]: ../struct.Key.html + pub fn set_color(&mut self, color: impl Into>) { + self.color = color.into(); + } +} + +impl Default for Spinner { + fn default() -> Self { + Spinner { + t: 0.0, + color: theme::LABEL_COLOR.into(), + } + } +} + +impl Widget for Spinner { + fn event(&mut self, _ctx: &mut EventCtx, _event: &Event, _data: &mut T, _env: &Env) {} + + fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, _data: &T, _env: &Env) { + if let LifeCycle::WidgetAdded = event { + ctx.request_anim_frame(); + } + + if let LifeCycle::AnimFrame(interval) = event { + self.t += (*interval as f64) * 1e-9; + if self.t >= 1.0 { + self.t = 0.0; + } + ctx.request_anim_frame(); + } + } + + fn update(&mut self, _ctx: &mut UpdateCtx, _old_data: &T, _data: &T, _env: &Env) {} + + fn layout( + &mut self, + _layout_ctx: &mut LayoutCtx, + bc: &BoxConstraints, + _data: &T, + env: &Env, + ) -> Size { + bc.debug_check("Spinner"); + + if bc.is_width_bounded() && bc.is_height_bounded() { + bc.max() + } else { + bc.constrain(Size::new( + env.get(theme::BASIC_WIDGET_HEIGHT), + env.get(theme::BASIC_WIDGET_HEIGHT), + )) + } + } + + fn paint(&mut self, ctx: &mut PaintCtx, _data: &T, env: &Env) { + let t = self.t; + let (width, height) = (ctx.size().width, ctx.size().height); + let center = Point::new(width / 2.0, height / 2.0); + let (r, g, b, original_alpha) = Color::as_rgba(&self.color.resolve(env)); + let scale_factor = width.min(height) / 40.0; + + for step in 1..=12 { + let step = f64::from(step); + let fade_t = (t * 12.0 + 1.0).trunc(); + let fade = ((fade_t + step).rem_euclid(12.0) / 12.0) + 1.0 / 12.0; + let angle = Vec2::from_angle((step / 12.0) * -2.0 * PI); + let ambit_start = center + (10.0 * scale_factor * angle); + let ambit_end = center + (20.0 * scale_factor * angle); + let color = Color::rgba(r, g, b, fade * original_alpha); + + ctx.stroke( + Line::new(ambit_start, ambit_end), + &color, + 3.0 * scale_factor, + ); + } + } +}