1use frus_core::{Rect, Scene};
5use frus_layout::{Align, FlexDirection, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::{FillAxes, Widget};
10
11pub struct StickyHeader<Msg = crate::callback::Callback> {
30 children: Vec<Box<dyn Widget<Msg>>>,
31}
32
33impl<Msg> StickyHeader<Msg> {
34 pub fn new(header: impl Widget<Msg> + 'static, content: impl Widget<Msg> + 'static) -> Self {
36 Self {
37 children: vec![Box::new(header), Box::new(content)],
38 }
39 }
40}
41
42impl<Msg: Clone> Widget<Msg> for StickyHeader<Msg> {
43 fn style(&self) -> Style {
44 Style {
45 flex_direction: FlexDirection::Column,
46 align: Align::Stretch,
47 flex_shrink: 0.0,
48 ..Style::default()
49 }
50 }
51
52 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
53 &self.children
54 }
55
56 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
57
58 fn on_click(&self) -> Option<Msg> {
59 None
60 }
61
62 fn sticky_header(&self) -> bool {
63 true
64 }
65
66 fn fill_axes(&self, _theme: &Theme) -> FillAxes {
68 FillAxes {
69 horizontal: true,
70 vertical: false,
71 }
72 }
73
74 fn debug_name(&self) -> &'static str {
75 "StickyHeader"
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::interaction::WidgetId;
83 use crate::{build_ui, Container, Flex, Runtime, SingleChildScrollView};
84 use frus_core::{Color, Point, Primitive, Size};
85
86 const RED: Color = Color::rgb(1.0, 0.0, 0.0);
87 const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
88 const GREY: Color = Color::rgb(0.5, 0.5, 0.5);
89
90 fn sections() -> SingleChildScrollView<u8> {
93 let section = |color: Color, says: u8| {
95 crate::keyed(
96 says,
97 StickyHeader::new(
98 Container::new().height(20.0).color(color).on_click(says),
99 Container::new().height(100.0).color(GREY).on_click(9),
100 ),
101 )
102 };
103 SingleChildScrollView::new()
104 .width(200.0)
105 .height(200.0)
106 .child(
107 Flex::column()
108 .child(section(RED, 1))
109 .child(section(BLUE, 2))
110 .child(Container::new().height(300.0)),
111 )
112 }
113
114 fn headers(offset: f32) -> (f32, f32) {
116 let mut runtime = Runtime::default();
117 runtime.scroll.insert(WidgetId::ROOT, (0.0, offset));
118 let ui = build_ui(
119 §ions(),
120 Size::new(200.0, 200.0),
121 &runtime,
122 &Theme::dark(),
123 );
124 let top = |color: Color| {
125 ui.scene()
126 .primitives()
127 .iter()
128 .find_map(|p| match p {
129 Primitive::Rect { rect, color: c, .. } if *c == color => Some(rect.y),
130 _ => None,
131 })
132 .expect("the header")
133 };
134 (top(RED), top(BLUE))
135 }
136
137 #[test]
139 fn at_rest_the_headers_are_where_they_were_laid_out() {
140 assert_eq!(headers(0.0), (0.0, 120.0));
141 }
142
143 #[test]
145 fn a_header_sticks_while_its_section_is_in_view() {
146 assert_eq!(headers(50.0), (0.0, 70.0));
147 }
148
149 #[test]
151 fn the_next_header_pushes_it_off() {
152 assert_eq!(headers(110.0), (-10.0, 10.0));
153 assert_eq!(headers(130.0), (-30.0, 0.0));
154 }
155
156 #[test]
159 fn a_stuck_header_is_over_its_section_and_takes_the_tap() {
160 let mut runtime = Runtime::default();
161 runtime.scroll.insert(WidgetId::ROOT, (0.0, 50.0));
162 let ui = build_ui(
163 §ions(),
164 Size::new(200.0, 200.0),
165 &runtime,
166 &Theme::dark(),
167 );
168 let order: Vec<Color> = ui
169 .scene()
170 .primitives()
171 .iter()
172 .filter_map(|p| match p {
173 Primitive::Rect { color, .. } if *color == RED || *color == GREY => Some(*color),
174 _ => None,
175 })
176 .collect();
177 assert_eq!(order.first(), Some(&GREY), "the body first: {order:?}");
178 assert_eq!(order.get(1), Some(&RED), "then its header: {order:?}");
179 let hit = ui.hit(Point::new(100.0, 10.0)).expect("something takes it");
180 assert_eq!(ui.msg_for(hit), Some(1));
181 let hit = ui.hit(Point::new(100.0, 40.0)).expect("the body below");
182 assert_eq!(ui.msg_for(hit), Some(9));
183 }
184
185 #[test]
188 fn a_header_sticks_in_a_list() {
189 let list = crate::ListView::<()>::new(4, 120.0, |i| {
190 StickyHeader::new(
191 Container::<()>::new()
192 .height(20.0)
193 .color(if i == 0 { RED } else { BLUE }),
194 Container::<()>::new().height(100.0).color(GREY),
195 )
196 })
197 .width(200.0)
198 .height(200.0);
199 let mut runtime = Runtime::default();
200 runtime.scroll.insert(WidgetId::ROOT, (0.0, 50.0));
201 let ui = build_ui(&list, Size::new(200.0, 200.0), &runtime, &Theme::dark());
202 let tops: Vec<(Color, f32)> = ui
203 .scene()
204 .primitives()
205 .iter()
206 .filter_map(|p| match p {
207 Primitive::Rect { rect, color, .. } if *color == RED || *color == BLUE => {
208 Some((*color, rect.y))
209 }
210 _ => None,
211 })
212 .collect();
213 assert!(tops.contains(&(RED, 0.0)), "{tops:?}");
214 assert!(tops.contains(&(BLUE, 70.0)), "{tops:?}");
215 }
216}