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:
parent
1d18edef12
commit
7848311046
8 changed files with 283 additions and 74 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
crates/ui/src/list/loading.rs
Normal file
32
crates/ui/src/list/loading.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
mod list;
|
||||
mod list_item;
|
||||
mod loading;
|
||||
|
||||
pub use list::*;
|
||||
pub use list_item::*;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
crates/ui/src/table/loading.rs
Normal file
89
crates/ui/src/table/loading.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue