cranpose_foundation/lazy/
lazy_list_measure.rs1use super::bounds_adjuster::BoundsAdjuster;
8use super::item_measurer::ItemMeasurer;
9use super::lazy_list_measured_item::{LazyListMeasureResult, LazyListMeasuredItem};
10use super::lazy_list_state::{LazyListLayoutInfo, LazyListState};
11use super::scroll_position_resolver::ScrollPositionResolver;
12use super::viewport::ViewportHandler;
13use std::collections::VecDeque;
14
15pub const DEFAULT_ITEM_SIZE_ESTIMATE: f32 = 48.0;
19
20#[derive(Clone, Debug)]
22pub struct LazyListMeasureConfig {
23 pub is_vertical: bool,
25
26 pub reverse_layout: bool,
31
32 pub before_content_padding: f32,
34
35 pub after_content_padding: f32,
37
38 pub spacing: f32,
40
41 pub beyond_bounds_item_count: usize,
44
45 pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
48
49 pub horizontal_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
52}
53
54impl Default for LazyListMeasureConfig {
55 fn default() -> Self {
56 Self {
57 is_vertical: true,
58 reverse_layout: false,
59 before_content_padding: 0.0,
60 after_content_padding: 0.0,
61 spacing: 0.0,
62 beyond_bounds_item_count: 2,
63 vertical_arrangement: None,
64 horizontal_arrangement: None,
65 }
66 }
67}
68
69pub fn measure_lazy_list<F>(
89 items_count: usize,
90 state: &LazyListState,
91 viewport_size: f32,
92 _cross_axis_size: f32,
93 config: &LazyListMeasureConfig,
94 mut measure_item: F,
95) -> LazyListMeasureResult
96where
97 F: FnMut(usize) -> LazyListMeasuredItem,
98{
99 if items_count == 0 {
104 state.update_scroll_position(0, 0.0);
105 state.update_layout_info(LazyListLayoutInfo {
106 visible_items_info: Vec::new(),
107 total_items_count: 0,
108 viewport_size,
109 viewport_start_offset: config.before_content_padding,
110 viewport_end_offset: config.after_content_padding,
111 before_content_padding: config.before_content_padding,
112 after_content_padding: config.after_content_padding,
113 });
114 state.update_scroll_bounds();
115 return LazyListMeasureResult::default();
116 }
117
118 if viewport_size <= 0.0 {
121 state.update_layout_info(LazyListLayoutInfo {
123 visible_items_info: Vec::new(),
124 total_items_count: items_count,
125 viewport_size,
126 viewport_start_offset: config.before_content_padding,
127 viewport_end_offset: config.after_content_padding,
128 before_content_padding: config.before_content_padding,
129 after_content_padding: config.after_content_padding,
130 });
131 state.update_scroll_bounds();
132 return LazyListMeasureResult::default();
133 }
134
135 let viewport = ViewportHandler::new(viewport_size, state.average_item_size(), config.spacing);
137 let effective_viewport_size = viewport.effective_size();
138
139 let resolver = ScrollPositionResolver::new(state, config, items_count, effective_viewport_size);
141 let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
142 let mut pre_measured = Vec::new();
143
144 if first_offset < 0.0 && first_index > 0 {
146 (first_index, first_offset) = resolver.normalize_backward_jump(first_index, first_offset);
147 while first_offset < 0.0 && first_index > 0 {
148 first_index -= 1;
149 let item = measure_item(first_index);
150 first_offset += item.main_axis_size + config.spacing;
151 pre_measured.push(item);
152 }
153 pre_measured.reverse();
154 }
155
156 first_index = first_index.min(items_count.saturating_sub(1));
157 first_offset = first_offset.max(0.0);
158 (first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
159
160 let pre_measured_queue = VecDeque::from(pre_measured);
162 let mut measurer = ItemMeasurer::new(
163 &mut measure_item,
164 config,
165 items_count,
166 effective_viewport_size,
167 pre_measured_queue,
168 );
169 let mut visible_items = measurer.measure_all(first_index, first_offset);
170
171 let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
173 adjuster.clamp(&mut visible_items);
174
175 let total_content_size = estimate_total_content_size(
177 items_count,
178 &visible_items,
179 config,
180 state.average_item_size(),
181 );
182
183 let viewport_end = effective_viewport_size - config.after_content_padding;
185 let item_end_with_spacing = |item: &LazyListMeasuredItem| {
186 let spacing_after = if item.index + 1 < items_count {
187 config.spacing
188 } else {
189 0.0
190 };
191 item.offset + item.main_axis_size + spacing_after
192 };
193 let actual_first_visible = visible_items
194 .iter()
195 .find(|item| item_end_with_spacing(item) > config.before_content_padding);
196
197 let (final_first_index, final_scroll_offset) = if let Some(first) = actual_first_visible {
198 let offset = config.before_content_padding - first.offset;
199 (first.index, offset.max(0.0))
200 } else if !visible_items.is_empty() {
201 (visible_items[0].index, 0.0)
202 } else {
203 (0, 0.0)
204 };
205
206 if let Some(first) = actual_first_visible {
208 state.update_scroll_position_with_key(final_first_index, final_scroll_offset, first.key);
209 } else if !visible_items.is_empty() {
210 state.update_scroll_position_with_key(
211 final_first_index,
212 final_scroll_offset,
213 visible_items[0].key,
214 );
215 } else {
216 state.update_scroll_position(final_first_index, final_scroll_offset);
217 }
218 state.update_layout_info(LazyListLayoutInfo {
219 visible_items_info: visible_items
220 .iter()
221 .filter(|item| {
222 let item_end = item_end_with_spacing(item);
223 item_end > config.before_content_padding && item.offset < viewport_end
224 })
225 .map(|i| i.to_item_info())
226 .collect(),
227 total_items_count: items_count,
228 viewport_size: effective_viewport_size,
229 viewport_start_offset: config.before_content_padding,
230 viewport_end_offset: config.after_content_padding,
231 before_content_padding: config.before_content_padding,
232 after_content_padding: config.after_content_padding,
233 });
234
235 state.update_scroll_bounds();
237
238 let can_scroll_backward = final_first_index > 0 || final_scroll_offset > 0.0;
240 let can_scroll_forward = if let Some(last) = visible_items.last() {
241 last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_end
242 } else {
243 false
244 };
245
246 LazyListMeasureResult {
247 visible_items,
248 first_visible_item_index: final_first_index,
249 first_visible_item_scroll_offset: final_scroll_offset,
250 viewport_size: effective_viewport_size,
251 total_content_size,
252 can_scroll_forward,
253 can_scroll_backward,
254 }
255}
256
257fn estimate_total_content_size(
262 items_count: usize,
263 measured_items: &[LazyListMeasuredItem],
264 config: &LazyListMeasureConfig,
265 state_average_size: f32,
266) -> f32 {
267 if items_count == 0 {
268 return 0.0;
269 }
270
271 let avg_size = if !measured_items.is_empty() {
273 let total_measured_size: f32 = measured_items.iter().map(|i| i.main_axis_size).sum();
274 total_measured_size / measured_items.len() as f32
275 } else {
276 state_average_size
277 };
278
279 config.before_content_padding + (avg_size + config.spacing) * items_count as f32
280 - config.spacing
281 + config.after_content_padding
282}
283
284#[cfg(test)]
285mod tests {
286 use super::super::lazy_list_state::test_helpers::{
287 new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
288 };
289 use super::*;
290
291 fn create_test_item(index: usize, size: f32) -> LazyListMeasuredItem {
292 LazyListMeasuredItem::new(index, index as u64, None, size, 100.0)
293 }
294
295 #[test]
296 fn test_measure_empty_list() {
297 with_test_runtime(|| {
298 let state = new_lazy_list_state();
299 let config = LazyListMeasureConfig::default();
300
301 let result = measure_lazy_list(0, &state, 500.0, 300.0, &config, |_| {
302 panic!("Should not measure any items");
303 });
304
305 assert!(result.visible_items.is_empty());
306 });
307 }
308
309 #[test]
310 fn test_measure_single_item() {
311 with_test_runtime(|| {
312 let state = new_lazy_list_state();
313 let config = LazyListMeasureConfig::default();
314
315 let result = measure_lazy_list(1, &state, 500.0, 300.0, &config, |i| {
316 create_test_item(i, 50.0)
317 });
318
319 assert_eq!(result.visible_items.len(), 1);
320 assert_eq!(result.visible_items[0].index, 0);
321 assert!(!result.can_scroll_forward);
322 assert!(!result.can_scroll_backward);
323 });
324 }
325
326 #[test]
327 fn test_measure_fills_viewport() {
328 with_test_runtime(|| {
329 let state = new_lazy_list_state();
330 let config = LazyListMeasureConfig::default();
331
332 let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
334 create_test_item(i, 50.0)
335 });
336
337 assert!(result.visible_items.len() >= 4);
339 assert!(result.can_scroll_forward);
340 assert!(!result.can_scroll_backward);
341 });
342 }
343
344 #[test]
345 fn test_measure_with_scroll_offset() {
346 with_test_runtime(|| {
347 let state = new_lazy_list_state_with_position(3, 25.0);
348 let config = LazyListMeasureConfig::default();
349
350 let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
351 create_test_item(i, 50.0)
352 });
353
354 assert_eq!(result.first_visible_item_index, 3);
355 assert!(result.can_scroll_forward);
356 assert!(result.can_scroll_backward);
357 });
358 }
359
360 #[test]
361 fn test_backward_scroll_uses_measured_size() {
362 with_test_runtime(|| {
363 let state = new_lazy_list_state_with_position(1, 0.0);
364 state.dispatch_scroll_delta(1.0);
365 let config = LazyListMeasureConfig::default();
366
367 let result = measure_lazy_list(2, &state, 100.0, 300.0, &config, |i| {
368 if i == 0 {
369 create_test_item(i, 10.0)
370 } else {
371 create_test_item(i, 100.0)
372 }
373 });
374
375 assert_eq!(result.first_visible_item_index, 0);
376 assert!((result.first_visible_item_scroll_offset - 9.0).abs() < 0.001);
377 });
378 }
379
380 #[test]
381 fn test_backward_scroll_with_spacing_preserves_offset_gap() {
382 with_test_runtime(|| {
383 let state = new_lazy_list_state_with_position(1, 0.0);
384 let config = LazyListMeasureConfig {
385 spacing: 4.0,
386 ..Default::default()
387 };
388 state.dispatch_scroll_delta(2.0);
389
390 let result = measure_lazy_list(2, &state, 40.0, 300.0, &config, |i| {
391 create_test_item(i, 50.0)
392 });
393
394 assert_eq!(result.first_visible_item_index, 0);
395 assert!((result.first_visible_item_scroll_offset - 52.0).abs() < 0.001);
396 });
397 }
398
399 #[test]
400 fn test_scroll_to_item() {
401 with_test_runtime(|| {
402 let state = new_lazy_list_state();
403 state.scroll_to_item(5, 0.0);
404
405 let config = LazyListMeasureConfig::default();
406 let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
407 create_test_item(i, 50.0)
408 });
409
410 assert_eq!(result.first_visible_item_index, 5);
411 });
412 }
413}