viridia / quill_v1

Reactive UI framework for Bevy game engine
MIT License
60 stars 7 forks source link

Allow `Views` to be passed as props to other `Views`. #14

Open viridia opened 9 months ago

viridia commented 9 months ago

One of the most common patterns in real-world UI code are widgets that are wrappers around other widgets. For example a UI toolkit might provide an "AppBar" or "DialogHeader" class which renders a styled container for its children.

A somewhat less common pattern, although still seen in many toolkits, are widgets that have multiple "slots", such as an "AppPage" widget that has both a "header" and "body" slots. Some UI toolkits seem to like this pattern a lot, others avoid it - it seems to be a matter of taste.

Even something as simple as a button can benefit: in some frameworks, if you want a button with both a label and an icon, there will be specific slots for the text label and the icon; but in other frameworks, you simply pass in single child that is a fragment containing both a text node and an icon node (or whatever combination of children you like). The latter approach is much more flexible but less artistically rigorous, it really depends on how strictly you want to enforce the style guidelines.

In any case, in order to make this work, we need a mechanism by which a View can be passed as a parameter to another View. Currently this doesn't work because View props are memoized, which means that they need to implement PartialEq, which Views currently do not (other than primitive Views such as String).

It's not really clear what it means for two views to be "equal". For example, you could recursively walk the entire tree of the view and all of its children and compare that with the other view, but that's probably overkill. On the other hand, if two views are different types/shapes, then they are obviously different - but this can never happen since views are generic parameters.

Note that when we're talking about comparing views we are talking about views without their associated state - to put it another way, changing the view's state should not cause it to become unequal, and thus should not be part of the equality comparison.

In practice, something like reference-equality might be good enough: the only thing we really care about is not breaking the callee's property memoization too often. In the worst case, each time the parent widget re-evaluates its presenter, it will generate a new view prop value which will compare unequal with the previous view prop value - thus forcing whatever child presenter to re-render since the prop value changed. This means that there will be times that the child presenter does a re-render even though visually nothing has changed. As long as the parent doesn't do this too often, it won't really matter.

Alternatively, we could try a bit harder to do equality comparisons on views, as long as we avoid false positives (false negatives are OK).

As far as the actual mechanics of passing views around, I'm thinking that the actual parameter type would be some special trait like impl ViewParam which wraps the View and provides the PartialEq implementation.

viridia commented 9 months ago

I've checked in a partial solution to this problem, although it has some shortcomings.

You can now wrap a View in a ViewParam, for example ViewParam::new(my_widget.bind(props)). ViewParam implements both Clone and PartialEq, which means it can be passed as a prop.

However, the PartialEq only does a pointer comparison on the inner view. Since most inner views are created as local variables, this means that the equality check will almost always return false. This means that whatever view the ViewParam is being passed to will unconditionally re-render.

The longer-term approach is to implement PartialEq and Clone for all of the various view types. Any view type which supports these traits can be passed directly without wrapping.

viridia commented 9 months ago

I've gradually been adding support for PartialEq and Clone to views. Until then, ViewParam works.