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.

FeatureDescription
Automatic GroupingDetect 3+ consecutive items with the same key and group them automatically
Efficient LookupsO(log n) lookups using RangeMap for checking group membership
Native Portal ListDirect PortalList inside FoldHeader body for optimal performance
Dynamic Text LabelsFoldButtonWithText 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}

Step 3: Implement GroupHeaderManager

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

ConditionAction
item_id == range.startRender FoldHeader
range.start < item_id < range.endRender Empty
Outside any rangeRender 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

OptimizationBenefit
RangeMap EfficiencyO(log n) lookups for checking if an item belongs to a group
Empty PlaceholdersItems within a group render as 0-height views with minimal overhead
Lazy Widget CreationWidgets in collapsed FoldHeaders aren't created until expanded
Cache Expensive ComputationsPre-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

  1. Call compute_groups() only when data changes, never in draw_walk()
  2. Use height: Fit for PortalList inside FoldHeader body
  3. Use makepad_widgets::fold_button::FoldButtonAction, not custom actions
  4. No need to specify "header" or "body" prefix when accessing FoldHeader children
  5. Render Empty for items within group range (except start)

Resources


Pattern by alanpoon | Source: Robrix