Making a widget in iced-rs

by dan

Iced is a native ui framework for rust. This post is intended to be a high-level overview of making a custom widget for other new iced users, particularly developers coming from React or similar.

What's a custom widget?

I'm making an old-school personal music player using iced. I wanted to have a 'hoverable' widget to reproduce the ux that spotify and youtube music have when looking at a playlist. It ended up being fairly straightforward, after I got some help from the iced discord.

Normal view code in iced is made of functions that return elements, similar to Elm, React, or the various other javascript frameworks that use a virtual DOM and diffing. To make that workflow possible for a native app, iced has its own DOM-like widget tree, and you can implement your own widgets at that lower layer.

Maybe this is a bad idea

Before doing that, though, it's worth emphasizing that this isn't the 'normal' way to handle a chunk of ui code in iced. If you want to reuse some view code, iced gives you a few options:

  1. Use a function
    This is the intended default. You write a normal rust function that takes arguments and returns an iced Element, and you call it.
    If you're coming from web development, you can compare this to view functions in Elm, or to classic, pre-hook 'pure' function components in React.

  2. Make a stateful Component
    An iced_lazy Component is the intended user-facing way to make a reusable stateful view. It has a similar update/view lifecycle to your main iced application.
    It's more like a miniature Elm app than a 'component' in javascript frameworks. So if you just want to reuse some view code, without giving it a rich and complex internal life, you probably want option 1.

  3. Make a custom Widget
    This is what we're doing. With the Widget trait, you get access to the way iced internally handles widget state, and can also modify/break layout, drawing, etc. My impression is that implementing this trait is intended to be a last resort once iced is in a more mature state.
    If you're coming from web development, you could compare this to web components. But instead of adding a new custom element type to the browser, you're adding one to iced's widget tree. Iced's widget tree is a bit newer than the DOM, so you may have to reach for this more often than you'd use a custom element on the web.

So if you're using iced, and you want a 'component', going straight to a custom widget might be a bad idea. If you can get what you want by composing existing elements/components in a new way, then you want option 1 or 2. If you want an element that doesn't exist yet, then you want option 3.

Let's do it anyway

Let's assume you have a need for option 3. For me, that was adding a 'hoverable' component. After some flailing around and getting direction on the discord (thanks Brock and Hector!), I landed on using internal state for whether the cursor was currently over the widget, and normal iced messages for enter and exit. Other than mouse events, this is basically the same as iced's standard button widget.

pub struct Hoverable<'a, Message, Renderer> {
    content: Element<'a, Message, Renderer>,
    on_hover: Message,
    on_unhover: Message,
    padding: Padding,
}

impl<'a, Message, Renderer> Hoverable<'a, Message, Renderer>
where
    Renderer: iced_native::Renderer,
{
    const WIDTH: Length = Length::Shrink;
    const HEIGHT: Length = Length::Shrink;

    pub fn new(
        content: Element<'a, Message, Renderer>,
        on_hover: Message,
        on_unhover: Message,
    ) -> Self {
        Self {
            content,
            on_hover,
            on_unhover,
            padding: Padding::ZERO,
        }
    }

    pub fn padding<P>(mut self, padding: P) -> Self
    where
        P: Into<Padding>,
    {
        self.padding = padding.into();
        self
    }
}

This defines an invisible 'shrunk' wrapper around a content element, while allowing padding for items in a column/row. The on_hover and on_unhover messages will be the equivalent of enter and exit events. The lifetime 'a is the lifetime of the internal content, and Message will be defined by the application. The Renderer type is defined by what platform you're building for (eg web vs native).

We'll also need a separate type for tracking the widget's mutable internal state.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct State {
    is_hovered: bool,
}

We could take is_hovered as a normal argument, but then we'd have to track in the app state, which results in a lot more messages going through the event loop.

Why is the mutable state in a separate struct, though? Widget states are in a separate tree! This is something I first encountered in iced, though it might be done elsewhere. There are 'parallel' trees for both layout and widget states, that can be traversed independently. Iced may add other kinds of trees in the future as well -- for example, for accessability information.

Now for the actual widget implementation, which will also be mostly straight from Button.

impl<'a, Message, Renderer> Widget<Message, Renderer>
    for Hoverable<'a, Message, Renderer>
where
    Message: 'a + Clone,
    Renderer: iced_native::Renderer,
{
    fn tag(&self) -> tree::Tag {
        tree::Tag::of::<State>()
    }

    fn state(&self) -> tree::State {
        tree::State::new(State::default())
    }

    fn children(&self) -> Vec<Tree> {
        vec![Tree::new(&self.content)]
    }

    fn diff(&self, tree: &mut Tree) {
        tree.diff_children(
            std::slice::from_ref(&self.content)
        );
    }

Catch your breath after that first part! We just need to restate those same types from Hoverable's own impl block. The tag method gives the framework a type id, to allow it to do dynamic type checks against the layout tree. We'll see an example later. The state method is for defining the widget's initial state; in this case 'not hovered', from the derived Default impl. The children method is saying Hoverable always has exactly one child; its content. The diff method again delegates to the child to let it also fix up its own state tree.

There are other similar methods that just delegate to the child, but those are the simple ones relevant for how hover works. Then there's on_event, which is where the important part happens:

    fn on_event(
        &mut self,
        tree: &mut Tree,
        event: Event,
        layout: Layout<'_>,
        cursor_position: Point,
        renderer: &Renderer,
        clipboard: &mut dyn Clipboard,
        shell: &mut Shell<'_, Message>,
    ) -> event::Status {
        if let event::Status::Captured = self
            .content
            .as_widget_mut()
            .on_event(
                &mut tree.children[0],
                event,
                layout.children().next().unwrap(),
                cursor_position,
                renderer,
                clipboard,
                shell,
            )
        {
            return event::Status::Captured;
        }

        let mut state = tree.state.downcast_mut::<State>();
        let was_hovered = state.is_hovered;
        let now_hovered = layout
            .bounds()
            .contains(cursor_position);

        match (was_hovered, now_hovered) {
            (true, true) => {}
            (false, false) => {}
            (true, false) => {
                // exited hover
                state.is_hovered = now_hovered;
                shell.publish(self.on_unhover.clone());
            }
            (false, true) => {
                // entered hover
                state.is_hovered = now_hovered;
                shell.publish(self.on_hover.clone());
            }
        }

        event::Status::Ignored
    }

The first chunk there is taken from button again, and delegates to the content in case it has its own event handling to do. The call to downcast_mut is where we're dynamically grabbing the current hover state from the state tree node. Then we find out if the state changed, update it, and emit the appropriate event.

It's worth noting that downcast_mut will panic if you have the wrong node - we're in the under-the-hood, dont-screw-up part of iced here.

Here's what using the widget in an app could look like. First, we need to track a unique id for the hovered item in our model:

#[derive(Debug)]
struct Ui {
    // ...
    hovered_song_id: Option<SongId>,
}

#[derive(Debug, Clone)]
pub enum Message {
    // ...
    HoveredSong(SongId),
    UnhoveredSong(SongId),
}

And then handle the enter/exit messages in our update function:

fn update(&mut self, message: Message) -> Command<Message> {
    match message {
        // ...
        Message::HoveredSong(song_id) => {
            self.hovered_song_id = Some(song_id);
            Command::none()
        }

        Message::UnhoveredSong(song_id) => {
            if self.hovered_song_id == Some(song_id) {
                self.hovered_song_id = None;
            }
            Command::none()
        }
    }
}

With the state and messages handled, we can use the widget in our view (here assuming we've built up song_row content previously):

Hoverable::new(
    song_row,
    Message::HoveredSong(song.id),
    Message::UnhoveredSong(song.id),
)
.padding(2)

That's that

The final code I ended up using is available here. It has some additional indirection with an Effect type that I may write up in its own post.

Iced is still alpha software, and if you use it you'll need to spend time reading the library rather than relying solely on docs. Even acknowledging that, I've found it nice to use, and definitely mature enough for hobby projects.

Some resources to keep an eye on: