Collapsible Portal List
Auto-group consecutive items under collapsible headers in Makepad.
Level: Advanced
Tags: portal-list, grouping, fold-header, rangemap
What is PortalList?
The PortalList widget efficiently handles large lists of items by only rendering the items currently visible in the viewport. It supports features like scrolling, flick scrolling, and alignment of items within the list. This is especially useful for implementing lists with a large number of items without compromising performance.
The Problem
When displaying large datasets in a portal list (like a news feed or message list), consecutive items with the same category or key can create visual clutter. Users need a way to collapse related items into groups to better scan and navigate the list, especially when 3 or more consecutive items share the same key.
The Solution
Use a GroupHeaderManager with RangeMap to track consecutive items with identical keys and automatically render them as FoldHeader widgets with collapsible content.
| Feature | Description |
|---|
| Automatic Grouping | Detect 3+ consecutive items with the same key and group them automatically |
| Efficient Lookups | O(log n) lookups using RangeMap for checking group membership |
| Native Portal List | Direct PortalList inside FoldHeader body for optimal performance |
| Dynamic Text Labels | FoldButtonWithText shows "Show More"/"Show Less" based on state |
Step 1: Project Setup
Add the required dependencies to your Cargo.toml:
1# Cargo.toml
2[dependencies]
3rangemap = "1.5"
4makepad-widgets = { path = "../../widgets" }
Register your custom widget module in lib.rs:
1// lib.rs or main.rs
2pub mod fold_button_with_text; // Custom widget
Step 2: Create FoldButtonWithText Widget
This custom widget extends the standard FoldButton with dynamic text labels that change based on the fold state.
IMPORTANT
You must use makepad_widgets::fold_button::FoldButtonActionfor action events. Do NOT create a custom FoldButtonAction type. This ensures compatibility with the FoldHeader widget system.
1use makepad_widgets::*;
2use makepad_widgets::widget::WidgetActionData;
3use makepad_widgets::fold_button::FoldButtonAction; // Use existing action type!
4
5#[derive(Live, Widget)]
6pub struct FoldButtonWithText {
7 #[animator] animator: Animator,
8 #[redraw] #[live] draw_bg: DrawQuad,
9 #[redraw] #[live] draw_text: DrawText,
10 #[walk] walk: Walk,
11 #[layout] layout: Layout,
12 #[live] active: f64,
13 #[live] triangle_size: f64,
14 #[live] open_text: ArcStringMut, // Text when collapsed
15 #[live] close_text: ArcStringMut, // Text when expanded
16 #[action_data] #[rust] action_data: WidgetActionData,
17}
18
19impl Widget for FoldButtonWithText {
20 fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
21 let uid = self.widget_uid();
22 let res = self.animator_handle_event(cx, event);
23
24 if res.is_animating() {
25 if self.animator.is_track_animating(cx, ids!(active)) {
26 let mut value = [0.0];
27 self.draw_bg.get_instance(cx, ids!(active), &mut value);
28 cx.widget_action(uid, &scope.path,
29 FoldButtonAction::Animating(value[0] as f64))
30 }
31 if res.must_redraw() {
32 self.draw_bg.redraw(cx);
33 }
34 }
35
36 match event.hits(cx, self.draw_bg.area()) {
37 Hit::FingerDown(_fe) => {
38 if self.animator_in_state(cx, ids!(active.on)) {
39 self.animator_play(cx, ids!(active.off));
40 cx.widget_action(uid, &scope.path, FoldButtonAction::Closing)
41 } else {
42 self.animator_play(cx, ids!(active.on));
43 cx.widget_action(uid, &scope.path, FoldButtonAction::Opening)
44 }
45 },
46 Hit::FingerHoverIn(_) => {
47 cx.set_cursor(MouseCursor::Hand);
48 self.animator_play(cx, ids!(hover.on));
49 }
50 _ => ()
51 }
52 }
53
54 fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
55 self.draw_bg.begin(cx, walk, self.layout);
56
57 // Dynamically select text based on state
58 let text = if self.active > 0.5 {
59 self.close_text.as_ref() // Expanded state
60 } else {
61 self.open_text.as_ref() // Collapsed state
62 };
63
64 let label_walk = walk.with_margin_left(self.triangle_size * 2.0 + 10.0);
65 self.draw_text.draw_walk(cx, label_walk, Align::default(), text);
66 self.draw_bg.end(cx);
67 DrawStep::done()
68 }
69}
The GroupHeaderManager uses RangeMap to efficiently track which items belong to groups. It provides O(log n) lookups for group membership.
1use std::{collections::HashMap, ops::Range};
2use rangemap::RangeMap;
3
4#[derive(Debug, Clone, Default)]
5struct GroupMeta {
6 key: String,
7 count: usize,
8}
9
10#[derive(Default)]
11struct GroupHeaderManager {
12 group_ranges: RangeMap<usize, String>,
13 groups_by_id: HashMap<String, GroupMeta>,
14}
15
16impl GroupHeaderManager {
17 fn new() -> Self {
18 Self {
19 group_ranges: RangeMap::new(),
20 groups_by_id: HashMap::new(),
21 }
22 }
23
24 /// Check if item_id is part of a group
25 fn check_group_header_status(&self, item_id: usize) -> Option<Range<usize>> {
26 for (range, _) in self.group_ranges.iter() {
27 if range.contains(&item_id) {
28 return Some(range.clone())
29 }
30 }
31 None
32 }
33
34 /// Get group metadata for the group starting at item_id
35 fn get_group_at_item_id(&self, item_id: usize) -> Option<&GroupMeta> {
36 self.group_ranges
37 .iter()
38 .find(|(range, _)| range.start == item_id)
39 .and_then(|(_, header_id)| self.groups_by_id.get(header_id))
40 }
41
42 /// Compute groups from data - call ONLY when data changes!
43 fn compute_groups(&mut self, data: &[(String, String)]) {
44 self.group_ranges.clear();
45 let mut i = 0;
46
47 while i < data.len() {
48 let current_key = &data[i].0;
49 let mut count = 1;
50
51 // Count consecutive items with same key
52 while i + count < data.len() && &data[i + count].0 == current_key {
53 count += 1;
54 }
55
56 // Only create groups for 3+ consecutive items
57 if count >= 3 {
58 let header_id = format!("{}_group_{}", current_key, i);
59 self.group_ranges.insert(i..i + count, header_id.clone());
60 self.groups_by_id.insert(header_id, GroupMeta {
61 key: current_key.clone(),
62 count,
63 });
64 }
65 i += count;
66 }
67 }
68}
CRITICAL
Call compute_groups() ONLY when data is first available or when data changes. NEVER call it during draw_walk()as it would recompute on every frame!
Wrong approach:
1fn draw_walk(...) {
2 // WRONG: Recomputes every frame!
3 self.group_manager.compute_groups(&self.data);
4 // ... rendering
5}
Correct approach:
1fn after_new_from_doc(...) {
2 self.data = load_data();
3 // CORRECT: Compute once!
4 self.group_manager.compute_groups(&self.data);
5}
Step 4: Define live_design! Structure
Set up your widget templates in live_design!. Note the direct PortalList inside the FoldHeader body with height: Fit.
1live_design! {
2 use link::widgets::*;
3 use crate::fold_button_with_text::*;
4
5 MyApp = <View> {
6 width: Fill, height: Fill
7
8 my_list = <PortalList> {
9 width: Fill, height: Fill
10
11 // Template for normal ungrouped items
12 Post = <View> {
13 width: Fill, height: 60
14 padding: 10
15 content = <View> {
16 text = <Label> { text: "" }
17 }
18 }
19
20 // Empty placeholder for items within groups
21 Empty = <View> { height: 0, show_bg: false }
22
23 // FoldHeader for grouped items
24 FoldHeader = <FoldHeader> {
25 header: <View> {
26 width: Fill, height: 50
27 align: { x: 0.5, y: 0.5 }
28
29 fold_button = <FoldButtonWithText> {
30 open_text: "Show More"
31 close_text: "Show Less"
32 }
33 summary_text = <Label> { text: "" }
34 }
35
36 body: <View> {
37 width: Fill, height: Fit
38 flow: Down
39
40 // Direct PortalList in body!
41 <PortalList> {
42 height: Fit, width: Fill // Use Fit, not 0!
43 Post = <Post> {}
44 }
45 }
46 }
47 }
48 }
49}
TIP
The PortalList inside FoldHeader body uses height: Fit to automatically size to its content. This is different from the old "dummy portal list" pattern that used height: 0.
Step 5: Portal List Integration
Integrate the GroupHeaderManager with your portal list's draw logic. The key is handling three cases based on item position relative to group ranges.
1impl Widget for MyPortalList {
2 fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
3 while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
4 if let Some(mut list) = item.as_portal_list().borrow_mut() {
5 list.set_item_range(cx, 0, self.data.len());
6
7 while let Some(item_id) = list.next_visible_item(cx) {
8 // Check if this item is part of a group
9 if let Some(range) = self.group_manager
10 .check_group_header_status(item_id) {
11
12 if item_id == range.start {
13 // CASE 1: Start of group - render FoldHeader
14 self.render_fold_header(cx, scope, &mut list,
15 item_id, &range, walk);
16 } else {
17 // CASE 2: Within group - render Empty
18 list.item(cx, item_id, live_id!(Empty))
19 .draw_all(cx, &mut Scope::empty());
20 }
21 } else {
22 // CASE 3: Outside group - normal item
23 self.render_normal_item(cx, &mut list, item_id);
24 }
25 }
26 }
27 }
28 DrawStep::done()
29 }
30}
31
32impl MyPortalList {
33 fn render_fold_header(&mut self, cx: &mut Cx2d, scope: &mut Scope,
34 list: &mut PortalList, item_id: usize,
35 range: &Range<usize>, walk: Walk) {
36 let group_meta = self.group_manager
37 .get_group_at_item_id(item_id).unwrap();
38
39 // Get FoldHeader from portal list
40 let fold_item = list.item(cx, item_id, live_id!(FoldHeader));
41
42 // Set header text (no "header" prefix needed!)
43 fold_item.label(ids!(summary_text))
44 .set_text(cx, &format!("{} ({} items)",
45 group_meta.key, group_meta.count));
46
47 // Draw FoldHeader and access inner PortalList
48 let mut walk = walk;
49 walk.height = Size::Fit; // IMPORTANT!
50
51 while let Some(item) = fold_item.draw_walk(cx, scope, walk).step() {
52 if let Some(mut list_ref) = item.as_portal_list().borrow_mut() {
53 let inner_list = list_ref.deref_mut();
54
55 // Render ALL items in the range
56 for idx in range.start..range.end {
57 if let Some((key, text)) = self.data.get(idx) {
58 let widget = inner_list.item(cx, idx, live_id!(Post));
59 widget.label(ids!(content.text))
60 .set_text(cx, &format!("{}: {}", key, text));
61 widget.draw_all(cx, scope);
62 }
63 }
64 }
65 }
66 }
67}
Step 6: Understanding the Rendering Flow
Three Rendering Modes
| Condition | Action |
|---|
item_id == range.start | Render FoldHeader |
range.start < item_id < range.end | Render Empty |
| Outside any range | Render Normal Item |
Example Walkthrough
Given data with Category A items at indices 0-2 (group) and Category C at 4-6 (group):
:::info Indices 0-2 (Category A Group)
next_visible_item() returns 0
- Check:
item_id == range.start (0 == 0) → TRUE
- Render FoldHeader at position 0 with ALL items (0, 1, 2)
next_visible_item() returns 1 → Render Empty
next_visible_item() returns 2 → Render Empty
:::
:::info Index 3 (Category B - Not Grouped)
- Not part of any group → Render normal Post template
:::
:::info Indices 4-6 (Category C Group)
next_visible_item() returns 4
- Check:
item_id == range.start (4 == 4) → TRUE
- Render FoldHeader at position 4 with ALL items (4, 5, 6)
- Items 5 and 6 render as Empty when visited
:::
Performance Considerations
| Optimization | Benefit |
|---|
| RangeMap Efficiency | O(log n) lookups for checking if an item belongs to a group |
| Empty Placeholders | Items within a group render as 0-height views with minimal overhead |
| Lazy Widget Creation | Widgets in collapsed FoldHeaders aren't created until expanded |
| Cache Expensive Computations | Pre-compute summaries and metadata when groups are created |
Caching Example
1impl SmallStateGroup {
2 pub fn update_cached_data(&mut self) {
3 // Cache expensive computations when groups are created
4 self.cached_summary = Some(generate_summary(
5 &self.user_events_map,
6 SUMMARY_LENGTH
7 ));
8 self.cached_avatar_user_ids = Some(extract_avatar_user_ids(
9 &self.user_events_map,
10 MAX_VISIBLE_AVATARS
11 ));
12 }
13}
14
15// Use cached data during rendering
16if let Some(summary) = &group.cached_summary {
17 fold_item.label(ids!(summary_text)).set_text(cx, summary);
18}
When to Use This Pattern
Good Use Cases
- News feeds grouped by topic/author
- Message lists grouped by thread
- File browsers grouped by type
- E-commerce catalogs grouped by category
- Event lists grouped by date
Avoid When
- Items don't have natural grouping keys
- Groups expected to be smaller than 3 items
- Groups span non-consecutive items
- Manual grouping control is required
Quick Reference
Key Components
- GroupHeaderManager - Tracks consecutive items using RangeMap
- FoldHeader - Built-in Makepad widget for collapsible sections
- FoldButtonWithText - Custom widget with dynamic labels
- PortalList - Efficient virtualized list rendering
Key Rules
- Call
compute_groups() only when data changes, never in draw_walk()
- Use
height: Fit for PortalList inside FoldHeader body
- Use
makepad_widgets::fold_button::FoldButtonAction, not custom actions
- No need to specify "header" or "body" prefix when accessing FoldHeader children
- Render Empty for items within group range (except start)
Resources
Pattern by alanpoon | Source: Robrix