In search of a better Bevy UI


A few months ago I read this excellent blog post on the challenges and opportunities in the future of bevy_ui. If you haven’t read it yet, I recommend you do, since it provides quite some context for what I’m about to talk about here.

Myself, I built this lovely Sudoku Pi project and while doing so, I ended up building a custom UI layer as an alternative to Bevy UI. The main blocker for us, that made me decide not to use Bevy UI, was the inability with Bevy UI to add custom transforms to nodes. This effectively eliminating any opportunity to add animations to them. And animations were very important to us.

But the lack of animations isn’t the only problem with Bevy UI, as the post mentioned above points out in excellent detail. Some of the other issues we have been able to side-step using our own custom UI layer, but many we didn’t. What I can say though is, that I saw many of these problems up close, and as such I have opinions on them. Some of these opinions might even be valuable to others :)

To tamper expectations, please don’t expect detailed solutions from this post. The UI solution I built is highly specific to Sudoku Pi, and it shares enough of the limitations of Bevy UI that I wouldn’t recommend adopting it wholesale. But if you’re in a position where you need to build a similar solution, or maybe you’re someone wishing to improve Bevy UI itself, hopefully these thoughts are useful to you.

But, to Bevy’s credit, there’s one thing I like to share upfront: Building a custom UI solution isn’t actually that hard. Bevy already provides you with the building blocks, so if you can implement a layout algorithm, you’re mostly there. The trickier part is making something that is actually pleasant to use.

Layout

Just like Bevy UI, I decided to create a flex-inspired layout system. It shouldn’t really be a surprise, since like many, I have a fair share of web experience. I’m not really a fan of CSS though, so when the other blog mentioned Morphorm as being their preferred layout algorithm, I checked it out and regretted I had not tried it out instead. A missed opportunity on my part!

It wasn’t a huge loss, since I only needed to implement a flex-like subset that was sufficient for a single project. But I like Morphorm’s simplicity, and I do hope one day Bevy UI will adopt it or something like it. I didn’t try it out yet, but I’m particularly intrigued by how they solve respecting aspect ratios by allowing you to define width or height using a ratio of the other. (My own solution was a preserve_aspect_ratio boolean, which was still better than the hoops you may need to jump through with CSS, but I admit it was a bit awkward to implement.)

Expressions

That said, there is one thing my own layout system implemented, which I didn’t see in either Bevy UI or Morphorm: The ability to calculate values through expressions. Every now and then, you may want to perform a calculation like the following:

height - 2. * line_height

This may not appear too complicated, but there’s a catch: Bevy UI has a Val enum that allows you to express values such as Val::Px(300.) or Val::Percent(50.). So what if the height above is a percentage value, but the line_height is a pixel value? Wherever you define your values, you may not know how many pixels fit in a percentage, so you cannot perform the calculation on the spot. You need to somehow pass this expression to your layout system and let it perform the calculation for you. CSS once again offers us a suggestion: It allows us to express such calculations like this:

calc(50% - 2 * 300px)

Similarly, my own layout system has a Val enum just like Bevy’s, but with an additional variant: Val::Calc(Box<Expr>), where Expr is a type that allows for composing expressions that themselves also use Val values.

The real kicker? Thanks to Rust’s operating overloading, the expression that you see above just works and automatically evaluates to a Val, simply because height and line_height are (see source).

Transforms and animations

Before I move on to the next step, let me say just a few things about Transform. After all, Bevy UI’s inability to let me touch its transforms was the main reason I opted to create my own layout system in the first place.

The solution I opted to use was very straightforward: I have a FlexItemStyle component (my version of Bevy UI’s Style component), and I simply added another Transform to it. During the layout phase my algorithm checks if the transform is set to anything but the default value, and if it is, it applies the Transform from the FlexItemStyle to the item’s Transform, on top of the scaling and the translation that were determined by the layout algorithm.

It’s implemented right here.

Then, I’m able to apply animations to these custom transforms using bevy_tweening, a crate I’ve absolutely learned to love during my time with Bevy. I saw the other blog suggest that Bevy should have their own tweening too, but I’d go one step further: Why not integrate the bevy_tweening crate into Bevy wholesale? There’s probably intricacies I’m unaware of, but if it can be done, I’d be in favor.

Verbosity

The first time I saw how hierarchies are created with Bevy UI, I balked. Oh my. But then I learned it’s not even Bevy UI in particular that’s at fault here. It’s simply how Bevy entity hierarchies are created in general. And so it quickly turned out that even creating my own UI system didn’t really help here. Being able to only create children for an entity within a callback feels too limited. There’s probably reasons at work here that I’m not privy to, although I suspect it has to do with wanting to optimize for GPU batching. Either way, it’s clear to me the Bevy devs are skilled at what they do, so I don’t doubt they have good reasons. But whatever the reasons, it does not make for a very compelling UI development experience.

So here I am, with my own little UI system, but hamstrung by Bevy’s verbose method for defining hierarchies. Could I somehow work around it to make things more ergonomic? I started experimenting with my own APIs.

In the following examples I will use a few code snippets that represent our timer “widget”. It’s basically just a text node surrounded by two horizontal borders, one on the top and one on the bottom. Nowadays, Bevy UI has some built-in support for borders, but back when I started they didn’t, and my own layout system doesn’t support them either. So the two borders are just additional nodes that I need to insert myself.

Here’s what it looks like with a standard Bevy UI coding style:

fn build_timer(cb: &mut ChildBuilder, resources: &ResourceBag) {
    let width = Val::Pixel(100);
    let height = Val::Pixel(42);
    let line_height = Val::Pixel(1);

    let text_style = TextStyle {
        font: resources.fonts.medium.clone(),
        font_size: 70.,
        color: COLOR_TIMER_TEXT,
    };

    cb.spawn((
        FlexItemBundle {
            style: FlexItemStyle {
                flex_base: Size::new(width.clone(), line_height.clone()),
                ..default()
            },
            ..default()
        },
        SpriteBundle {
            sprite: Sprite::from_color(COLOR_TIMER_BORDER),
            ..default()
        },
    ));

    cb.spawn(FlexBundle {
        item: FlexItemBundle {
            style: FlexItemStyle {
                flex_base: Size::new(width.clone(), height - 2. * line_height.clone()),
                flex_grow: 1.,
                ..default()
            },
            ..default()
        }
        ..default()
    })
    .with_children(|text_leaf| {
        text_leaf.spawn((
            Timer,
            FlexTextBundle {
                text: Text2dBundle {
                    text: Text::from_section("0:00", text_style),
                    ..default()
                },
                ..default()
            },
        ));
    });

    cb.spawn((
        FlexItemBundle {
            style: FlexItemStyle {
                flex_base: Size::new(width, line_height),
                ..default()
            },
            ..default()
        },
        SpriteBundle {
            sprite: Sprite::from_color(COLOR_TIMER_BORDER),
            ..default()
        },
    ));
}

In order to improve things just a little bit, the first thing I did was to define builder methods for commonly constructed bundles and components. Soon enough, the same snippet started looking like this:

fn build_timer(cb: &mut ChildBuilder, resources: &ResourceBag) {
    let width = Val::Pixel(100);
    let height = Val::Pixel(42);
    let line_height = Val::Pixel(1);

    let text_style = TextStyle {
        font: resources.fonts.medium.clone(),
        font_size: 70.,
        color: COLOR_TIMER_TEXT,
    };

    cb.spawn((
        FlexItemBundle::from_style(FlexItemStyle::fixed_size(
            width.clone(),
            line_height.clone(),
        )),
        SpriteBundle {
            sprite: Sprite::from_color(COLOR_TIMER_BORDER),
            ..default()
        },
    ));

    cb.spawn(FlexBundle::from_item_style(FlexItemStyle::minimum_size(
        width.clone(),
        height - 2. * line_height.clone(),
    )))
    .with_children(|text_leaf| {
        text_leaf.spawn((
            Timer,
            FlexTextBundle::from_text(Text::from_section("0:00", text_style)),
        ));
    });

    cb.spawn((
        FlexItemBundle::from_style(FlexItemStyle::fixed_size(width, line_height)),
        SpriteBundle {
            sprite: Sprite::from_color(COLOR_TIMER_BORDER),
            ..default()
        },
    ));
}

I wouldn’t call it a huge improvement, but it was still significantly more readable than the initial syntax. In fact, I was happy enough that I built the first version of our game this way. But the structure was still the same, and while it got the job done, the verbosity quickly became worse again whenever I needed to make layouts responsive. When I added iPad support, I ended up with all these ugly if resources.screen_sizing.is_tablet() checks in my code:

fn build_timer(cb: &mut ChildBuilder, resources: &ResourceBag) {
    let width = if resources.screen_sizing.is_tablet() {
        Val::Pixel(150)
    } else {
        Val::Pixel(100)
    };
    let height = if resources.screen_sizing.is_tablet() {
        Val::Pixel(64)
    } else {
        Val::Pixel(42)
    };
    let line_height = Val::Pixel(1);

    let text_style = TextStyle {
        font: resources.fonts.medium.clone(),
        font_size: if resources.screen_sizing.is_tablet() {
            105.
        } else {
            70.
        },
        color: COLOR_TIMER_TEXT,
    };

    cb.spawn((
        FlexItemBundle::from_style(FlexItemStyle::fixed_size(
            width.clone(),
            line_height.clone(),
        )),
        SpriteBundle {
            sprite: Sprite::from_color(COLOR_TIMER_BORDER),
            ..default()
        },
    ));

    cb.spawn(FlexBundle::from_item_style(FlexItemStyle::minimum_size(
        width.clone(),
        height - 2. * line_height.clone(),
    )))
    .with_children(|text_leaf| {
        text_leaf.spawn((
            Timer,
            FlexTextBundle::from_text(Text::from_section("0:00", text_style)),
        ));
    });

    cb.spawn((
        FlexItemBundle::from_style(FlexItemStyle::fixed_size(width, line_height)),
        SpriteBundle {
            sprite: Sprite::from_color(COLOR_TIMER_BORDER),
            ..default()
        },
    ));
}

Ouch.

Well, at least I got the iPad version delivered. But now I wanted to branch out with Android support and suddenly the amount of screen ratios would become too many to test. And I also wanted to support the Steam Deck, which meant landscape support.

I needed a better way to handle responsiveness.

Responsiveness

As I started to think about how I could improve the responsiveness without going through months of tedious testing and manual tweaking of endless screen sizes, I had yet another look at how I compose these “widgets”. If I could somehow make my UI definitions easier, with the ability to resize and relayout at runtime, while centralizing the code responsible for the styling, instead of littering pixel counts and break points and other magic values all across the codebase, then maybe making things responsive was actually feasible.

Redesigning my UI that way was also months of work, but at least it was fun work :)

And I managed.

Compare the following snippet with the last one from the previous section:

fn timer() -> impl FnOnce(&Props, &mut ChildBuilder) {
    fragment3(
        rect(COLOR_TIMER_BORDER, game_screen_timer_line_size),
        row(
            game_screen_timer_inner_size,
            (),
            text_t(
                Timer,
                "0:00",
                (
                    font_medium,
                    game_screen_timer_font_size,
                    text_color(COLOR_TIMER_TEXT),
                ),
            ),
        ),
        rect(COLOR_TIMER_BORDER, game_screen_timer_line_size),
    )
}

This snippet is significantly easier to read and to maintain. It’s by no means perfect, and the comparison with the previous snippet is also a little unfair, because I have pushed all the styling into helper functions.

But its strength also lies in these very helper functions. With a bit of squinting, you could even say the way it reads is conceptually similar to adding CSS classes to the UI nodes. Either way, I think the readability is a huge improvement.

And readability isn’t even the only advantage. I don’t just run these functions when the UI is constructed to calculate dimensions and such, I also let them register dynamic styles. This way, whenever I need to relayout (for instance, when I resize a window on my desktop, or when a user rotates their phone) these dynamic styles get to re-evaluate and the UI becomes truly responsive.

This allows me to resize a window and quickly confirm visually whether the UI scales correctly with various screen sizes and aspect ratios.

Mission accomplished.

Closing Words

As fun as the experiment with Bevy’s UI system was, I do have to admit it was a lot of work to get a mere Sudoku app out there. If I handn’t been such a tech nerd, I probably would have quit a lot sooner :)

Unfortunately (in this context), my professional priorities have also changed recently, since I’m becoming a contractor. That means I will be unlikely to take this project much further. It is my hope however that this post provided some inspiration to bring Bevy UI to the next level.

If you would like to hear more on this topic, I will also give a talk about the challenges we ran into with Sudoku Pi at the next online Bevy Meetup on March 12. Some of the things discussed here will certainly make a reappearance!

Thanks for reading and maybe till the 12th!