Reactivity in Bevy: From the Bottom Up (Part 3)

·

11 min read

In Part 1 of this series, I talked about the basic primitives that make up reactivity, and in Part 2 I described working with reactive contexts. Now it's time to put all that infrastructure to work, and actually creating something that is visible on the screen.

Views

In bevy_reactor, the term display graph is used to mean the set of entities that are actually rendered. These entities could be a user interface made up of Bevy UI nodes, or they could be a 3D scene made up of mesh bundles.

A View is a type of entity which generates or maintains a display graph. It is separate from the display graph, meaning that there's a separate tree of view entities. The nodes within this tree consist of views, tracking scopes, and reactions.

Views are, in their simplest form, a type of reaction: they have a react() method which is invoked when their dependencies change, but they also have lifecycle methods which are used to create and destroy the display graph.

Views are implemented using the View trait, which has 5 methods:

  • build(view_entity, world) which is called when the view is first created.

  • raze(view_entity, world) which is called when the view is about to be destroyed.

  • nodes() which returns the display graph nodes for this view.

  • react(view_entity, world, tracking) which is called when the view's dependencies change.

  • children_changed(view_entity, world), which is a method used to inform the view that the display graph of one or more of its child views have changed.

The general pattern in bevy_reactor is that trait objects are stored within components by wrapping them in a "handle" type, and View is no exception: there is a ViewHandle type which contains an Arc<Mutex<dyn View>>.

Views are powered by a set of ECS systems which manage their lifecycles.

Built-in View implementations

Bevy_reactor contains a bunch of pre-built implementations of View:

  • Element<Bundle> is the most general type of View, it creates a display node by spawning a bundle.

  • Fragment is a view which can have multiple children, but does not create a display node by itself - instead, the children are added to the parent view of the fragment. This can be handy when you have a function that produces a variable number of child views, but needs to return them as a single object.

  • Portal is a view which can have multiple children, but the children are intended to be "top-level" (that is, parentless) display nodes. The Portal view overrides the normal mechanism for adding children to their parents. This is useful for things like modal dialogs or popup menus which need to be absolutely positioned on the window, but which are still part of the view hierarchy.

  • Cond represents a "conditional" view, like an if-statement. It accepts three arguments, all of which are generic functions: the first argument is the test expression, and the second and third arguments are the "then" branch and the "else" branch, respectively. Cond is reactive, meaning that the choice of which branch gets rendered will change when the dependencies of the test expression change.

  • Switch is a reactive switch statement, allowing multiple cases to be supported.

  • For::each(), For::keyed(), and For::index() are reactive versions of a for loop, which will be described in a later section.

  • TextStatic is a view which simply displays a text string. A static string of text which is used as a child of a view will automatically be converted into a TextStatic view.

  • TextComputed is a view which generates a text string dynamically, used when you want something like a counter or a dynamic display.

  • Finally, EmptyView is a view that displays nothing. It's what happens when you pass () as a child element.

View Children

Some view types, such as Element and Fragment, support adding child views: these child views generate display nodes which are then added as children to the display node generated by the parent view. A view implementation can indicate that it wants to support children by implementing the ParentView trait.

ParentView has a number of methods, but the most important one is .with_children(), which is used to attach child views to the parent. The .with_children() method is both generic and recursive, and can accept a single view, a tuple of views, a tuple of tuples, and so on - it's very much like Bevy's IntoSystem trait. It also supports automatic conversions, so types like &str and () will automatically be transformed into the appropriate view types.

Child views are expected to provide their display graph nodes via the nodes() method of View. This method returns a NodeSpan object:

#[derive(Debug, Clone)]
pub enum NodeSpan {
    Empty,
    Node(Entity),
    Fragment(Box<[NodeSpan]>),
}

A NodeSpan can represent either an empty list, a single entity, or an array of NodeSpan. The parent will transform the NodeSpan into a "flat" list of child entities and attach them to the parent display node using .set_children().

You might ask at this point, "Why not just return a list of entities?" and the answer is because of something called "incrementalization", a concept which is at the heart of just about every UI framework in any programming language. Incrementalization simply means a set of techniques for doing partial updates on a complex graph - that is, being able to change parts of the tree without having to rebuild the whole thing.

Imagine for a moment that we have a view which has three children:

  1. A text label

  2. A for-loop which generates some number of radio buttons

  3. A button widget

Now, the number of child views is three, but the number of display node children is variable, depending on how many radio buttons get generated. What happens if the for-loop "reacts" to a change in the data, causing additional radio buttons to be created? In this case, we want a way to replace only the items generated by #2 without disturbing the items generated by #1 and #3.

When this happens, the for loop places a DisplayNodeChanged marker component on the view entity for the for-loop. The bevy_reactor framework notices this (in the attach_child_views system), and walks up the view hierarchy looking for a view that can accept children (via the .children_changed() trait method). When it finds a view that returns true from this method, it knows that the updated children have been handled successfully. So for example, if the parent view was an Element, that view would flatten the list of all its child view display nodes and then set them as children of its own display node.

View Effects

A typical display node, such as a UI element or a mesh object, is more than just its children. It also has ECS components for things like color, shape and so on. We want these kinds of attributes to be reactive as well, and the way to do this is through view effects. A View implementation can indicate that it wants to support view effects by implementing EffectTarget.

EffectTarget has a bunch of different methods, but here are some of the most commonly-used ones.

The .insert() method simply inserts a Bundle into the display node. It does this only once, when the display node is first built. In this respect, it's almost identical to the Bevy insert() method on Commands.

The .insert_computed() method, by contrast, creates a reaction: that is, a computation which is run multiple times. The argument to .insert_computed() is a closure which returns a Bundle. This bundle will be re-inserted (replacing the previous bundle) whenever the dependencies change.

The .insert_computed() method is an example of an effect which spawns a reaction. Most effects, in fact, create reactions. These "child reactions" are separate entities which are owned by the View (or more precisely, owned by the TrackingScope associated with the view).

What if you want to modify an ECS component rather than replacing it? In that case, we can use the more powerful .create_effect() method. This method accepts a closure which takes two parameters: A context object (cx) and an entity id - the entity id of the display node. The closure can use this entity id to make whatever changes it wants.

For example, here's a complex effect that computes the background color of a checkbox, depending on whether the checkbox is checked, pressed, or hovering state:

checkbox_view.create_effect(move |cx, ent| {
    let is_checked = checked.get(cx);
    let is_pressed = pressed.get(cx);
    let is_hovering = hovering.get(cx);
    let color = match (is_checked, is_pressed, is_hovering) {
        (true, true, _) => colors::ACCENT.darker(0.1),
        (true, false, true) => colors::ACCENT.darker(0.15),
        (true, _, _) => colors::ACCENT.darker(0.2),
        (false, true, _) => colors::U1.lighter(0.005),
        (false, false, true) => colors::U1.lighter(0.002),
        (false, false, false) => colors::U1,
    };
    let mut bg = cx.world_mut().get_mut::<BackgroundColor>(ent).unwrap();
    bg.0 = color.into();
})

This effect depends on the value of three signals: checked, pressed, and hovering. The background color will be recomputed whenever any of these three signals changes.

Dynamic Views

Bevy_reactor supports a number of methods for conditionally rendering a view, such as Cond, Switch and For. For example, you could render a text string indicating whether a mutable counter was even or odd:

Cond::new(
    |cx| {
        let counter = cx.use_resource::<Counter>();
        counter.count & 1 == 0
    },
    || "Even",
    || "Odd",
),

Internally, Cond tracks which branch was taken - the true/then branch or the false/else branch. Whenever the branch changes, Cond razes the view for the previous branch, and builds the view for the next branch.

The For::each() view accepts a reactive function that returns an iterator, and then calls a second function which generates a view for each element returned by the iterator.

For::each(
    |cx| {
        [0, 1, 2].into_iter()
    },
    |item| format!("item: {}", item),
),

The resulting View is smart enough to detect when individual array elements have changed (it does a diff against the previous output of the iterator), and only update the child views which have been added, deleted, or modified. This requires that the iterable elements support PartialEq; if they don't, then you can use either the For::keyed() or For::index() variants, which use either a derived comparison key or a numerical index.

You may notice that some of the closures passed to Cond and For don't have a Cx parameter. These functions are not reactive, there's no tracking scope. However the value returned from the closure can be a View, and as such can support reactivity on its own.

Custom Views

To actually build something useful, we'll want to go beyond the built-in view types and define our own views. There are two different styles of doing this: one based on functions (called "presenters"), and one based on structs (called "view templates").

A presenter is simply a function which takes a Cx object as input, and returns an impl View as output:

fn sample_presenter(cx: &mut Cx) -> impl View {
    Element::<NodeBundle>::new()
        .with_children("Hello, World!")
}

Presenters can also accept properties on the Cx object:

pub struct Props {
    pub name: String;
}

fn props_presenter(cx: &mut Cx<Props>) -> impl View {
    Element::<NodeBundle>::new()
        .with_children(cx.props.name)
}

Note, however, that in this example name is not reactive: remember, the presenter is only called once, not multiple times as in React. If you want to pass in a dynamic value, then generally what you want is to pass in a Signal, in this case, Signal<String>.

To call a presenter, you first need to bind it. Binding a presenter essentially creates a "binding" object that wraps up the function pointer and its properties. Bevy_reactor will later call this object with a reactive context:

Element::<NodeBundle>::new()
    .with_children(
        props_presenter.bind(Props { name: "Hello".to_string() })
    )

You can use a bound presenter anywhere that you can use a View. In effect, the Bind object returned by .bind() implements the View trait, and contains a minimal implementation of a View:

pub struct Bind<F: 'static, P: PresenterFn<F>> {
    /// Reference to presenter function.
    presenter: P,

    /// Props to pass to presenter function.
    props: Option<P::Props>,

    /// The view handle for the presenter output.
    inner: Option<Entity>,

    /// Display nodes.
    nodes: NodeSpan,
}

The other way to create a custom view is to define a struct that supports the ViewTemplate trait. This works just like a presenter, except that:

  • Instead of Props, the parameters are stored in the struct itself.

  • Instead of calling a function, the object's .create(cx) method is called.

For example, suppose you wanted to define a standard Button widget. You could do this as a view template:

struct Button {
    label: String
}

impl ViewTemplate for Button {
    fn create(cx: &mut Cx) -> impl View + Send + Sync + 'static {
        Element::<NodeBundle>::new()
            .with_children(TextStatic::new(self.label))
    }
}

To use the view template, simply drop it in to another view as a child view:

Element::<NodeBundle>::new()
    .with_children(Button { label: "Hello" })

View Roots

Now that we've created our custom view, we need some way to add it to the Bevy world. This can be done by spawning a ViewRoot:

fn setup_view_root(camera: In<Entity>, mut commands: Commands) {
    commands.spawn(ViewRoot::new(ui_main.bind(*camera)));
}

In the case of a UI, you'll often want the view to accept a camera parameter so that you can set TargetCamera on the root of your view, as shown here.

Within bevy_reactor, there's an ECS system which detects when a new view root has been added, and kicks off the lifecycle for building the views. This means calling .build() on the root view and all of its descendants, and spawning any tracking scopes, reactions, or display nodes that may be required.