Skip to main content

iced_widget_kit/
expander.rs

1//! A flexible container widget that can open or close in any direction to reveal or conceal its contents.
2//!
3//! # Example
4//! ```ignore
5//! enum Message {
6//!     ExpanderPressed,
7//! }
8//!
9//! let mut expander = expander(
10//!     button("Header").on_press(Message::ExpanderPressed),
11//!     column!["Item 1", "Item 2", "Item 3"],
12//! );
13//!
14//! if self.is_expanded {
15//!     expander = expander.expand();
16//! }
17//! ```
18use iced_core::{
19    Alignment, Animation, Clipboard, Element, Event, Layout, Length, Padding, Rectangle, Shell,
20    Size, Vector, Widget,
21    animation::Easing,
22    layout::{self, Limits, Node, flex::Axis},
23    mouse::{Cursor, Interaction},
24    overlay,
25    widget::{
26        Tree,
27        tree::{self, Tag},
28    },
29    window,
30};
31
32use std::{
33    borrow,
34    sync::atomic::{self, AtomicUsize},
35    time::Instant,
36};
37
38/// A collapsible container widget that can expand or collapse in any direction to show or hide
39/// its content.
40///
41/// The [`Expander`] widget provides a header that is always visible and a content
42/// section that can be toggled between expanded (visible) and collapsed (hidden)
43/// states. It is commonly used to conserve space in the UI while still allowing
44/// access to additional details on demand.
45///
46/// Both the header and content sections can host anything from plain text to more complex
47/// layouts.
48///
49/// # Example
50/// ```ignore
51/// enum Message {
52///     ExpanderPressed,
53/// }
54///
55/// let mut expander = expander(
56///     button("Header").on_press(Message::ExpanderPressed),
57///     column!["Item 1", "Item 2", "Item 3"],
58///     self.is_expanded,
59/// );
60/// ```
61pub struct Expander<'a, Message, Theme = iced_core::Theme, Renderer = iced_widget::Renderer> {
62    // Combine into array for flex layout function
63    header_content: [Element<'a, Message, Theme, Renderer>; 2],
64    id: Option<Id>,
65    width: Length,
66    height: Length,
67    direction: Direction,
68    is_expanded: bool,
69}
70
71/// Identifies an [`Expander`], primarily to indicate when animation state should be reset.
72#[derive(Clone, Debug, PartialEq, Eq, Hash)]
73pub struct Id(IdInternal);
74
75#[derive(Clone, Debug, PartialEq, Eq, Hash)]
76enum IdInternal {
77    Unique(usize),
78    Str(borrow::Cow<'static, str>),
79}
80
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
82pub enum Direction {
83    Left,
84    Up,
85    Right,
86    #[default]
87    Down,
88}
89
90impl<'a, Message, Theme, Renderer> Expander<'a, Message, Theme, Renderer> {
91    /// Creates a new [`Expander`] from the provided header and content.
92    #[must_use]
93    pub fn new<H, C>(header: H, content: C, is_expanded: bool) -> Self
94    where
95        H: Into<Element<'a, Message, Theme, Renderer>>,
96        C: Into<Element<'a, Message, Theme, Renderer>>,
97    {
98        Self {
99            header_content: [header.into(), content.into()],
100            id: None,
101            width: Length::Shrink,
102            height: Length::Shrink,
103            direction: Direction::Down,
104            is_expanded,
105        }
106    }
107
108    /// Set the id of the [`Expander`].
109    #[must_use]
110    pub fn id(mut self, id: impl Into<Id>) -> Self {
111        self.id = Some(id.into());
112        self
113    }
114
115    /// Sets the width of the [`Expander`].
116    #[must_use]
117    pub fn width(mut self, width: impl Into<Length>) -> Self {
118        self.width = width.into();
119        self
120    }
121
122    /// Sets the height of the [`Expander`].
123    #[must_use]
124    pub fn height(mut self, height: impl Into<Length>) -> Self {
125        self.height = height.into();
126        self
127    }
128
129    /// Sets which way the contents are displayed when the [`Expander`] is expanded.
130    #[must_use]
131    pub fn direction(mut self, direction: Direction) -> Self {
132        self.direction = direction;
133        self
134    }
135}
136
137static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
138
139impl Id {
140    /// Creates a new [`Id`] from a static`str`.
141    pub const fn new(id: &'static str) -> Self {
142        Self(IdInternal::Str(borrow::Cow::Borrowed(id)))
143    }
144
145    /// Creates a unique [`Id`].
146    ///
147    /// This function produces a different [`Id`] every time it is called.
148    pub fn unique() -> Self {
149        Self(IdInternal::Unique(
150            NEXT_ID.fetch_add(1, atomic::Ordering::Relaxed),
151        ))
152    }
153}
154
155struct State {
156    id: Option<Id>,
157    now: Option<Instant>,
158    animation: Animation<bool>,
159    was_animating: bool,
160    open_factor: f32,
161    direction: Option<Direction>,
162}
163
164impl State {
165    fn animation(is_expanded: bool) -> Animation<bool> {
166        Animation::new(is_expanded)
167            .easing(Easing::EaseOutQuart)
168            .slow()
169    }
170
171    fn new(id: Option<Id>, is_expanded: bool) -> Self {
172        Self {
173            id,
174            now: None,
175            animation: State::animation(is_expanded),
176            was_animating: false,
177            open_factor: is_expanded.into(),
178            direction: None,
179        }
180    }
181
182    fn reset_animation(&mut self, is_expanded: bool) {
183        self.animation = State::animation(is_expanded);
184    }
185}
186
187fn is_fully_closed<'a, Message, Theme, Renderer>(
188    expander: &Expander<'a, Message, Theme, Renderer>,
189    state: &State,
190) -> bool {
191    match state.now {
192        Some(now) => !expander.is_expanded && !state.animation.is_animating(now),
193        None => !expander.is_expanded,
194    }
195}
196
197impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
198    for Expander<'_, Message, Theme, Renderer>
199where
200    Renderer: iced_core::Renderer,
201{
202    fn size(&self) -> Size<Length> {
203        Size::new(self.width, self.height)
204    }
205
206    fn tag(&self) -> Tag {
207        Tag::of::<State>()
208    }
209
210    fn state(&self) -> tree::State {
211        tree::State::new(State::new(self.id.clone(), self.is_expanded))
212    }
213
214    fn children(&self) -> Vec<Tree> {
215        self.header_content.iter().map(Tree::new).collect()
216    }
217
218    fn diff(&self, tree: &mut Tree) {
219        let state = tree.state.downcast_mut::<State>();
220
221        if self.id != state.id {
222            *state = State::new(self.id.clone(), self.is_expanded);
223        } else {
224            tree.diff_children(&self.header_content);
225        }
226    }
227
228    fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node {
229        let state = tree.state.downcast_ref::<State>();
230
231        if is_fully_closed(self, state) {
232            let header_limits = limits.width(self.width).height(self.height);
233
234            let header_node = self.header_content[0].as_widget_mut().layout(
235                &mut tree.children[0],
236                renderer,
237                &header_limits,
238            );
239
240            Node::with_children(header_node.size(), vec![header_node])
241        } else {
242            let axis = match self.direction {
243                Direction::Down | Direction::Up => Axis::Vertical,
244                Direction::Left | Direction::Right => Axis::Horizontal,
245            };
246
247            // Use flex layout to calculate desired sizes when fully open
248            let target_node = layout::flex::resolve(
249                axis,
250                renderer,
251                limits,
252                self.width,
253                self.height,
254                Padding::ZERO,
255                0.0,
256                Alignment::Start,
257                &mut self.header_content,
258                &mut tree.children,
259            );
260
261            let child_nodes = target_node.children();
262            let mut header_node = child_nodes[0].clone();
263            let header_size = header_node.size();
264            let content_target_size = child_nodes[1].size();
265
266            let content_wrapper_size = match self.direction {
267                Direction::Left | Direction::Right => Size {
268                    width: content_target_size.width * state.open_factor,
269                    height: content_target_size.height * state.open_factor.ceil(),
270                },
271                Direction::Up | Direction::Down => Size {
272                    width: content_target_size.width * state.open_factor.ceil(),
273                    height: content_target_size.height * state.open_factor,
274                },
275            };
276
277            let content_limits = Limits::new(Size::ZERO, content_wrapper_size);
278
279            let mut content_node = self.header_content[1].as_widget_mut().layout(
280                &mut tree.children[1],
281                renderer,
282                &content_limits,
283            );
284
285            match self.direction {
286                Direction::Right => {
287                    let dx = content_target_size.width - content_wrapper_size.width;
288                    content_node.translate_mut([-dx, 0.0]);
289                }
290                Direction::Down => {
291                    let dy = content_target_size.height - content_wrapper_size.height;
292                    content_node.translate_mut([0.0, -dy]);
293                }
294                _ => {}
295            }
296
297            let mut content_wrapper_node =
298                Node::with_children(content_wrapper_size, vec![content_node]);
299
300            match self.direction {
301                Direction::Left => header_node.move_to_mut([content_wrapper_size.width, 0.0]),
302                Direction::Up => header_node.move_to_mut([0.0, content_wrapper_size.height]),
303                Direction::Right => content_wrapper_node.move_to_mut([header_size.width, 0.0]),
304                Direction::Down => content_wrapper_node.move_to_mut([0.0, header_size.height]),
305            }
306
307            let size = match self.direction {
308                Direction::Left | Direction::Right => Size::new(
309                    header_size.width + content_wrapper_size.width,
310                    header_size.height.max(content_wrapper_size.height),
311                ),
312                Direction::Up | Direction::Down => Size::new(
313                    header_size.width.max(content_wrapper_size.width),
314                    header_size.height + content_wrapper_size.height,
315                ),
316            };
317
318            Node::with_children(size, vec![header_node, content_wrapper_node])
319        }
320    }
321
322    fn operate(
323        &mut self,
324        tree: &mut Tree,
325        layout: Layout<'_>,
326        renderer: &Renderer,
327        operation: &mut dyn iced_core::widget::Operation,
328    ) {
329        operation.container(None, layout.bounds());
330
331        operation.traverse(&mut |operation| {
332            // Header
333            self.header_content[0].as_widget_mut().operate(
334                &mut tree.children[0],
335                layout.child(0),
336                renderer,
337                operation,
338            );
339
340            // Content - only operate on if at least partially open
341            if !is_fully_closed(self, tree.state.downcast_ref::<State>()) {
342                self.header_content[1].as_widget_mut().operate(
343                    &mut tree.children[1],
344                    layout.child(1).child(0),
345                    renderer,
346                    operation,
347                );
348            }
349        });
350    }
351
352    fn update(
353        &mut self,
354        tree: &mut Tree,
355        event: &Event,
356        layout: Layout<'_>,
357        cursor: Cursor,
358        renderer: &Renderer,
359        clipboard: &mut dyn Clipboard,
360        shell: &mut Shell<'_, Message>,
361        viewport: &Rectangle,
362    ) {
363        // Header
364        self.header_content[0].as_widget_mut().update(
365            &mut tree.children[0],
366            event,
367            layout.child(0),
368            cursor,
369            renderer,
370            clipboard,
371            shell,
372            viewport,
373        );
374
375        // Content - only update if at least partially open
376        if !is_fully_closed(self, tree.state.downcast_ref::<State>()) {
377            let wrapper_layout = layout.child(1);
378
379            self.header_content[1].as_widget_mut().update(
380                &mut tree.children[1],
381                event,
382                wrapper_layout.child(0),
383                cursor,
384                renderer,
385                clipboard,
386                shell,
387                &wrapper_layout.bounds(),
388            );
389        }
390
391        if shell.is_event_captured() {
392            return;
393        }
394
395        if let Event::Window(window::Event::RedrawRequested(now)) = event {
396            let state = tree.state.downcast_mut::<State>();
397            state.now = Some(*now);
398
399            if state.animation.value() != self.is_expanded {
400                state.direction = Some(self.direction);
401                state.animation.go_mut(self.is_expanded, *now);
402                state.open_factor = self.is_expanded.into();
403            }
404
405            if state.animation.is_animating(*now) {
406                // Stop animation if user has changed direction
407                if state
408                    .direction
409                    .is_some_and(|direction| self.direction != direction)
410                {
411                    state.was_animating = false;
412                    state.reset_animation(self.is_expanded);
413                } else {
414                    state.was_animating = true;
415                    shell.request_redraw();
416                }
417
418                // Scale target size of content by a factor determined by animation progress
419                let open_factor = state.animation.interpolate(0.0, 1.0, *now);
420
421                if open_factor != state.open_factor {
422                    state.open_factor = open_factor;
423                    shell.invalidate_layout();
424                }
425            } else if state.was_animating {
426                state.was_animating = false;
427                state.open_factor = self.is_expanded.into();
428                shell.request_redraw();
429                shell.invalidate_layout();
430            }
431        }
432    }
433
434    fn draw(
435        &self,
436        tree: &Tree,
437        renderer: &mut Renderer,
438        theme: &Theme,
439        style: &iced_core::renderer::Style,
440        layout: Layout<'_>,
441        cursor: Cursor,
442        viewport: &Rectangle,
443    ) {
444        // Header
445        self.header_content[0].as_widget().draw(
446            &tree.children[0],
447            renderer,
448            theme,
449            style,
450            layout.child(0),
451            cursor,
452            viewport,
453        );
454
455        // Content - only draw if at least partially open
456        if !is_fully_closed(self, tree.state.downcast_ref::<State>()) {
457            let wrapper_layout = layout.child(1);
458
459            if let Some(viewport) = wrapper_layout.bounds().intersection(viewport) {
460                self.header_content[1].as_widget().draw(
461                    &tree.children[1],
462                    renderer,
463                    theme,
464                    style,
465                    wrapper_layout.child(0),
466                    cursor,
467                    &viewport,
468                );
469            }
470        }
471    }
472
473    fn mouse_interaction(
474        &self,
475        tree: &Tree,
476        layout: Layout<'_>,
477        cursor: iced_core::mouse::Cursor,
478        viewport: &Rectangle,
479        renderer: &Renderer,
480    ) -> Interaction {
481        let header_layout = layout.child(0);
482        let is_over_header = cursor.is_over(header_layout.bounds());
483
484        if is_over_header {
485            return self.header_content[0].as_widget().mouse_interaction(
486                &tree.children[0],
487                header_layout,
488                cursor,
489                viewport,
490                renderer,
491            );
492        }
493
494        // Content - only check if at least partially open
495        if !is_fully_closed(self, tree.state.downcast_ref::<State>()) {
496            let content_wrapper_layout = layout.child(1);
497            let is_over_content = cursor.is_over(content_wrapper_layout.bounds());
498
499            if is_over_content {
500                return self.header_content[1].as_widget().mouse_interaction(
501                    &tree.children[1],
502                    content_wrapper_layout.child(0),
503                    cursor,
504                    &content_wrapper_layout.bounds(),
505                    renderer,
506                );
507            }
508        }
509
510        Interaction::default()
511    }
512
513    fn overlay<'a>(
514        &'a mut self,
515        tree: &'a mut Tree,
516        layout: Layout<'a>,
517        renderer: &Renderer,
518        viewport: &Rectangle,
519        translation: Vector,
520    ) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
521        let is_fully_closed = is_fully_closed(self, tree.state.downcast_ref::<State>());
522        let mut elements = self.header_content.iter_mut();
523        let mut children = tree.children.iter_mut();
524
525        let header_overlay = elements.next().unwrap().as_widget_mut().overlay(
526            children.next().unwrap(),
527            layout.child(0),
528            renderer,
529            viewport,
530            translation,
531        );
532
533        let content_overlay = if !is_fully_closed {
534            let wrapper_layout = layout.child(1);
535
536            elements.next().unwrap().as_widget_mut().overlay(
537                children.next().unwrap(),
538                wrapper_layout.child(0),
539                renderer,
540                &wrapper_layout.bounds(),
541                translation,
542            )
543        } else {
544            None
545        };
546
547        if header_overlay.is_some() || content_overlay.is_some() {
548            Some(
549                overlay::Group::with_children(
550                    header_overlay.into_iter().chain(content_overlay).collect(),
551                )
552                .overlay(),
553            )
554        } else {
555            None
556        }
557    }
558}
559
560impl<'a, Message, Theme, Renderer> From<Expander<'a, Message, Theme, Renderer>>
561    for Element<'a, Message, Theme, Renderer>
562where
563    Message: 'a,
564    Theme: 'a,
565    Renderer: 'a + iced_core::Renderer,
566{
567    fn from(expander: Expander<'a, Message, Theme, Renderer>) -> Self {
568        Element::new(expander)
569    }
570}
571
572impl From<&'static str> for Id {
573    fn from(value: &'static str) -> Self {
574        Self::new(value)
575    }
576}
577
578impl From<String> for Id {
579    fn from(value: String) -> Self {
580        Self(IdInternal::Str(borrow::Cow::Owned(value)))
581    }
582}