From 784831104644fd62208bca92ab7ed993aadf914e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 14 Jan 2025 11:36:33 +0800 Subject: [PATCH] list, table: Add default loading animation to List and Table. (#546) image image --- crates/story/src/list_story.rs | 11 +++ crates/story/src/table_story.rs | 10 +++ crates/ui/src/list/list.rs | 124 +++++++++++++++++++------------- crates/ui/src/list/loading.rs | 32 +++++++++ crates/ui/src/list/mod.rs | 1 + crates/ui/src/skeleton.rs | 37 ++++++---- crates/ui/src/table.rs | 53 ++++++++++---- crates/ui/src/table/loading.rs | 89 +++++++++++++++++++++++ 8 files changed, 283 insertions(+), 74 deletions(-) create mode 100644 crates/ui/src/list/loading.rs create mode 100644 crates/ui/src/table/loading.rs diff --git a/crates/story/src/list_story.rs b/crates/story/src/list_story.rs index dd37142f..7ca25db3 100644 --- a/crates/story/src/list_story.rs +++ b/crates/story/src/list_story.rs @@ -10,6 +10,7 @@ use gpui::{ use ui::{ button::Button, + checkbox::Checkbox, h_flex, label::Label, list::{List, ListDelegate, ListItem}, @@ -352,6 +353,16 @@ impl Render for ListStory { list.scroll_to_item(list.delegate().items_count(cx) - 1, cx); }) })), + ) + .child( + Checkbox::new("loading") + .label("Loading") + .checked(self.company_list.read(cx).loading()) + .on_click(cx.listener(|this, check: &bool, cx| { + this.company_list.update(cx, |this, cx| { + this.set_loading(*check, cx); + }) + })), ), ) .child( diff --git a/crates/story/src/table_story.rs b/crates/story/src/table_story.rs index 1e45f883..d7f0bc7b 100644 --- a/crates/story/src/table_story.rs +++ b/crates/story/src/table_story.rs @@ -772,6 +772,16 @@ impl Render for TableStory { .selected(delegate.fixed_cols) .on_click(cx.listener(Self::toggle_fixed_cols)), ) + .child( + Checkbox::new("loading") + .label("Loading") + .checked(self.table.read(cx).loading()) + .on_click(cx.listener(|this, check: &bool, cx| { + this.table.update(cx, |this, cx| { + this.set_loading(*check, cx); + }) + })), + ) .child( Checkbox::new("refresh-data") .label("Refresh Data") diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 01d65a44..53d12ed4 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -18,6 +18,8 @@ use gpui::{ use gpui::{px, ScrollStrategy}; use smol::Timer; +use super::loading::Loading; + actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]); pub fn init(cx: &mut AppContext) { @@ -65,6 +67,11 @@ pub trait ListDelegate: Sized + 'static { None } + /// Returns a Element to show when loading, default is built-in Skeleton loading view. + fn render_loading(&self, cx: &mut ViewContext>) -> impl IntoElement { + Loading + } + /// Return the confirmed index of the selected item. fn confirmed_index(&self, cx: &AppContext) -> Option { None @@ -110,6 +117,7 @@ pub struct List { query_input: Option>, last_query: Option, selectable: bool, + querying: bool, loading: bool, scrollbar_visible: bool, vertical_scroll_handle: UniformListScrollHandle, @@ -148,6 +156,7 @@ where max_height: None, scrollbar_visible: true, selectable: true, + querying: false, loading: false, size: Size::default(), _search_task: Task::ready(()), @@ -186,6 +195,17 @@ where self } + /// Get the loading state of the list. + pub fn loading(&self) -> bool { + self.loading + } + + /// Sets list loading state. + pub fn set_loading(&mut self, loading: bool, cx: &mut ViewContext) { + self.loading = loading; + cx.notify(); + } + pub fn set_query_input(&mut self, query_input: View, cx: &mut ViewContext) { cx.subscribe(&query_input, Self::on_query_input_event) .detach(); @@ -270,7 +290,7 @@ where return; } - self.set_loading(true, cx); + self.set_querying(true, cx); let search = self.delegate.perform_search(&text, cx); self._search_task = cx.spawn(|this, mut cx| async move { @@ -285,7 +305,7 @@ where // Always wait 100ms to avoid flicker Timer::after(Duration::from_millis(100)).await; let _ = this.update(&mut cx, |this, cx| { - this.set_loading(false, cx); + this.set_querying(false, cx); }); }); } @@ -294,10 +314,10 @@ where } } - fn set_loading(&mut self, loading: bool, cx: &mut ViewContext) { - self.loading = loading; + fn set_querying(&mut self, querying: bool, cx: &mut ViewContext) { + self.querying = querying; if let Some(input) = &self.query_input { - input.update(cx, |input, cx| input.set_loading(loading, cx)) + input.update(cx, |input, cx| input.set_loading(querying, cx)) } cx.notify(); } @@ -467,10 +487,6 @@ where .size_full() .relative() .overflow_hidden() - .on_action(cx.listener(Self::on_action_cancel)) - .on_action(cx.listener(Self::on_action_confirm)) - .on_action(cx.listener(Self::on_action_select_next)) - .on_action(cx.listener(Self::on_action_select_prev)) .when_some(self.query_input.clone(), |this, input| { this.child( div() @@ -483,46 +499,58 @@ where .child(input), ) }) - .map(|this| { - if let Some(view) = initial_view { - this.child(view) - } else { - this.child( - v_flex() - .flex_grow() - .relative() - .when_some(self.max_height, |this, h| this.max_h(h)) - .overflow_hidden() - .when(items_count == 0, |this| { - this.child(self.delegate().render_empty(cx)) - }) - .when(items_count > 0, |this| { - this.child( - uniform_list(view, "uniform-list", items_count, { - move |list, visible_range, cx| { - list.load_more_if_need(visible_range.clone(), cx); - - visible_range - .map(|ix| list.render_list_item(ix, cx)) - .collect::>() - } - }) - .flex_grow() - .with_sizing_behavior(sizing_behavior) - .track_scroll(vertical_scroll_handle) - .into_any_element(), - ) - }) - .children(self.render_scrollbar(cx)), - ) - } + .when(self.loading, |this| { + this.child(self.delegate().render_loading(cx)) }) - // Click out to cancel right clicked row - .when(self.right_clicked_index.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, cx| { - this.right_clicked_index = None; - cx.notify(); - })) + .when(!self.loading, |this| { + this.on_action(cx.listener(Self::on_action_cancel)) + .on_action(cx.listener(Self::on_action_confirm)) + .on_action(cx.listener(Self::on_action_select_next)) + .on_action(cx.listener(Self::on_action_select_prev)) + .map(|this| { + if let Some(view) = initial_view { + this.child(view) + } else { + this.child( + v_flex() + .flex_grow() + .relative() + .when_some(self.max_height, |this, h| this.max_h(h)) + .overflow_hidden() + .when(items_count == 0, |this| { + this.child(self.delegate().render_empty(cx)) + }) + .when(items_count > 0, |this| { + this.child( + uniform_list(view, "uniform-list", items_count, { + move |list, visible_range, cx| { + list.load_more_if_need( + visible_range.clone(), + cx, + ); + + visible_range + .map(|ix| list.render_list_item(ix, cx)) + .collect::>() + } + }) + .flex_grow() + .with_sizing_behavior(sizing_behavior) + .track_scroll(vertical_scroll_handle) + .into_any_element(), + ) + }) + .children(self.render_scrollbar(cx)), + ) + } + }) + // Click out to cancel right clicked row + .when(self.right_clicked_index.is_some(), |this| { + this.on_mouse_down_out(cx.listener(|this, _, cx| { + this.right_clicked_index = None; + cx.notify(); + })) + }) }) } } diff --git a/crates/ui/src/list/loading.rs b/crates/ui/src/list/loading.rs new file mode 100644 index 00000000..f572f233 --- /dev/null +++ b/crates/ui/src/list/loading.rs @@ -0,0 +1,32 @@ +use super::ListItem; +use crate::{skeleton::Skeleton, v_flex}; +use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled}; + +#[derive(IntoElement)] +pub struct Loading; + +#[derive(IntoElement)] +struct LoadingItem; + +impl RenderOnce for LoadingItem { + fn render(self, _: &mut gpui::WindowContext) -> impl IntoElement { + ListItem::new("skeleton").disabled(true).child( + v_flex() + .gap_1p5() + .overflow_hidden() + .child(Skeleton::new().h_5().w_48().max_w_full()) + .child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()), + ) + } +} + +impl RenderOnce for Loading { + fn render(self, _: &mut gpui::WindowContext) -> impl IntoElement { + v_flex() + .py_2p5() + .gap_3() + .child(LoadingItem) + .child(LoadingItem) + .child(LoadingItem) + } +} diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index 91225757..1ec431b6 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,5 +1,6 @@ mod list; mod list_item; +mod loading; pub use list::*; pub use list_item::*; diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs index 266db5a3..cb59a68a 100644 --- a/crates/ui/src/skeleton.rs +++ b/crates/ui/src/skeleton.rs @@ -1,21 +1,28 @@ use crate::theme::ActiveTheme; use gpui::{ - bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, ParentElement as _, - RenderOnce, Styled, + bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, RenderOnce, Styled, }; use std::time::Duration; #[derive(IntoElement)] pub struct Skeleton { base: Div, + secondary: bool, } impl Skeleton { pub fn new() -> Self { Self { base: div().w_full().h_4().rounded_md(), + secondary: false, } } + + /// Set use secondary color. + pub fn secondary(mut self, secondary: bool) -> Self { + self.secondary = secondary; + self + } } impl Styled for Skeleton { @@ -26,17 +33,21 @@ impl Styled for Skeleton { impl RenderOnce for Skeleton { fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement { - div().child( - self.base.bg(cx.theme().skeleton).with_animation( - "skeleton", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(bounce(ease_in_out)), - move |this, delta| { - let v = 1.0 - delta * 0.5; - this.opacity(v) - }, - ), + let color = if self.secondary { + cx.theme().skeleton.opacity(0.5) + } else { + cx.theme().skeleton + }; + + self.base.bg(color).with_animation( + "skeleton", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(bounce(ease_in_out)), + move |this, delta| { + let v = 1.0 - delta * 0.5; + this.opacity(v) + }, ) } } diff --git a/crates/ui/src/table.rs b/crates/ui/src/table.rs index fb293ede..b9de45e5 100644 --- a/crates/ui/src/table.rs +++ b/crates/ui/src/table.rs @@ -19,6 +19,8 @@ use gpui::{ VisualContext as _, WindowContext, }; +mod loading; + actions!( table, [ @@ -172,6 +174,8 @@ pub struct Table { size: Size, /// The visible range of the rows and columns. visible_range: VisibleRangeState, + /// The loading state of the table. + loading: bool, } #[allow(unused)] @@ -277,6 +281,13 @@ pub trait TableDelegate: Sized + 'static { .into_any_element() } + /// Return a Element to show when table is loading, default is built-in Skeleton loading view. + /// + /// The size is the size of the Table. + fn render_loading(&self, size: Size, cx: &mut ViewContext>) -> impl IntoElement { + loading::Loading::new().size(size) + } + /// Return true to enable load more data when scrolling to the bottom. /// /// Default: true @@ -359,6 +370,7 @@ where size: Size::default(), scrollbar_visible: Edges::all(true), visible_range: VisibleRangeState::default(), + loading: false, }; this.prepare_col_groups(cx); @@ -411,6 +423,17 @@ where self } + /// Get the loading state of the list. + pub fn loading(&self) -> bool { + self.loading + } + + /// Sets list loading state. + pub fn set_loading(&mut self, loading: bool, cx: &mut ViewContext) { + self.loading = loading; + cx.notify(); + } + /// When we update columns or rows, we need to refresh the table. pub fn refresh(&mut self, cx: &mut ViewContext) { self.prepare_col_groups(cx); @@ -1369,12 +1392,23 @@ where this.rounded_md().border_1().border_color(cx.theme().border) }) .bg(cx.theme().table) - .child(inner_table) - .child(ScrollableMask::new( - cx.view().entity_id(), - Axis::Horizontal, - &horizontal_scroll_handle, - )) + .when(self.loading, |this| { + this.child(self.delegate().render_loading(self.size, cx)) + }) + .when(!self.loading, |this| { + this.child(inner_table) + .child(ScrollableMask::new( + cx.view().entity_id(), + Axis::Horizontal, + &horizontal_scroll_handle, + )) + .when(self.right_clicked_row.is_some(), |this| { + this.on_mouse_down_out(cx.listener(|this, _, cx| { + this.right_clicked_row = None; + cx.notify(); + })) + }) + }) .child(canvas( move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), |_, _, _| {}, @@ -1391,12 +1425,5 @@ where this.child(self.render_horizontal_scrollbar(cx)) }), ) - // Click out to cancel right clicked row - .when(self.right_clicked_row.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, cx| { - this.right_clicked_row = None; - cx.notify(); - })) - }) } } diff --git a/crates/ui/src/table/loading.rs b/crates/ui/src/table/loading.rs new file mode 100644 index 00000000..a77f988f --- /dev/null +++ b/crates/ui/src/table/loading.rs @@ -0,0 +1,89 @@ +use crate::{h_flex, skeleton::Skeleton, theme::ActiveTheme, v_flex, Size}; +use gpui::{prelude::FluentBuilder as _, IntoElement, ParentElement as _, RenderOnce, Styled}; + +#[derive(IntoElement)] +pub struct Loading { + size: Size, +} + +impl Loading { + pub fn new() -> Self { + Self { size: Size::Medium } + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } +} + +#[derive(IntoElement)] +struct LoadingRow { + header: bool, + size: Size, +} + +impl LoadingRow { + pub fn header() -> Self { + Self { + header: true, + size: Size::Medium, + } + } + + pub fn row() -> Self { + Self { + header: false, + size: Size::Medium, + } + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for LoadingRow { + fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement { + let paddings = self.size.table_cell_padding(); + let height = self.size.table_row_height() * 0.5; + + h_flex() + .gap_3() + .h(self.size.table_row_height()) + .overflow_hidden() + .pt(paddings.top) + .pb(paddings.bottom) + .pl(paddings.left) + .pr(paddings.right) + .items_center() + .justify_between() + .overflow_hidden() + .when(self.header, |this| this.bg(cx.theme().table_head)) + .when(!self.header, |this| { + this.border_t_1().border_color(cx.theme().table_row_border) + }) + .child( + h_flex() + .gap_3() + .flex_1() + .child(Skeleton::new().secondary(self.header).h(height).w_24()) + .child(Skeleton::new().secondary(self.header).h(height).w_48()) + .child(Skeleton::new().secondary(self.header).h(height).w_16()), + ) + .child(Skeleton::new().secondary(self.header).h(height).w_24()) + } +} + +impl RenderOnce for Loading { + fn render(self, _: &mut gpui::WindowContext) -> impl IntoElement { + v_flex() + .gap_0() + .child(LoadingRow::header().size(self.size)) + .child(LoadingRow::row().size(self.size)) + .child(LoadingRow::row().size(self.size)) + .child(LoadingRow::row().size(self.size)) + .child(LoadingRow::row().size(self.size)) + } +}