list, table: Add default loading animation to List and Table. (#546)

<img width="485" alt="image"
src="https://github.com/user-attachments/assets/568cbf3a-6e32-4b21-8816-5e3cb26acc82"
/>

<img width="755" alt="image"
src="https://github.com/user-attachments/assets/5e13d6b8-e6f4-4cc3-b1f6-9b378ff523a4"
/>
This commit is contained in:
Jason Lee 2025-01-14 11:36:33 +08:00 committed by GitHub
parent 1d18edef12
commit 7848311046
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 283 additions and 74 deletions

View file

@ -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(

View file

@ -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")

View file

@ -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<List<Self>>) -> impl IntoElement {
Loading
}
/// Return the confirmed index of the selected item.
fn confirmed_index(&self, cx: &AppContext) -> Option<usize> {
None
@ -110,6 +117,7 @@ pub struct List<D: ListDelegate> {
query_input: Option<View<TextInput>>,
last_query: Option<String>,
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>) {
self.loading = loading;
cx.notify();
}
pub fn set_query_input(&mut self, query_input: View<TextInput>, cx: &mut ViewContext<Self>) {
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>) {
self.loading = loading;
fn set_querying(&mut self, querying: bool, cx: &mut ViewContext<Self>) {
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::<Vec<_>>()
}
})
.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::<Vec<_>>()
}
})
.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();
}))
})
})
}
}

View file

@ -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)
}
}

View file

@ -1,5 +1,6 @@
mod list;
mod list_item;
mod loading;
pub use list::*;
pub use list_item::*;

View file

@ -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)
},
)
}
}

View file

@ -19,6 +19,8 @@ use gpui::{
VisualContext as _, WindowContext,
};
mod loading;
actions!(
table,
[
@ -172,6 +174,8 @@ pub struct Table<D: TableDelegate> {
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<Table<Self>>) -> 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>) {
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>) {
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();
}))
})
}
}

View file

@ -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))
}
}