flet-dev / flet

Flet enables developers to easily build realtime web, mobile and desktop apps in Python. No frontend experience required.
https://flet.dev
Apache License 2.0
9.82k stars 384 forks source link

Add declarative UI model supports? #470

Open YouJiacheng opened 1 year ago

YouJiacheng commented 1 year ago

If UI and business logic share same data and/or data are lists, imperative UI model is really verbose. If I use declarative UI model, following code is enough. But if I use imperative UI model, I need to implement proper setters for all its properties(and repeat rebuild code in setters) and hold reference to ListView or ListView.controls. Thus I need to properly NAME them.

There are only two hard things in Computer Science: cache invalidation and naming things.

class ListSelectionView(UserControl):
    def __init__(self, group: str, values: list[str], types: list[str], selected_index: int | None, on_select: Callable[[int], None], on_selected_click: Callable[[str], None]):
        super().__init__(expand=1)
        self.group = group
        self.values = values
        self.types = types
        self.selected_index = selected_index
        self.on_select = on_select
        self.on_selected_click = on_selected_click

    def build(self):
        def build_item(i: int, value: str, annotation: str, selected: bool):
            if selected:
                def on_click(_):
                    self.on_selected_click(value)
                return Draggable(
                    group=self.group,
                    content=Container(
                        Row([
                            Text(value, size=15),
                            Container(expand=1),
                            Text(annotation, size=15),
                        ]),
                        on_click=on_click,
                        ink=True, padding=5,
                        bgcolor=colors.BLUE_ACCENT,
                    ),
                    content_feedback=CircleAvatar(
                        content=Text(
                            value,
                            size=15,
                            style='labelLarge',
                        ),
                        radius=40, bgcolor=colors.BLUE_ACCENT
                    ),
                )

            def on_click(_):
                self.on_select(i)
            return Container(
                Row([
                    Text(value, size=15),
                    Container(expand=1),
                    Text(annotation, size=15),
                ]),
                on_click=on_click,
                ink=True,
                padding=5,
            )
        return ListView(
            [build_item(i, value, annotation, i == self.selected_index)
             for i, value, annotation in zip(itertools.count(), self.values, self.types)],
            expand=1,
        )

Currently I use following code to emulate declarative UI model:

class FlutterControl(UserControl):
    def build(self):
        self.container = Container(self.construct(), expand=1)
        return self.container

    def construct(self) -> Control | None:
        return None

    def apply(self):
        self.container.content = self.construct()
        self.update()

But it has a problem: rebuilding will reset scrolling position of ListView.

YouJiacheng commented 1 year ago

I know flet is aiming for imperative programming model. It combines smaller "widgets" to ready-to-use "controls" to construct UI with less nesting levels, which is suitable for imperative UI model. But if the use-case is complex, nesting is unavoidable, which is not friendly to imperative UI model -- traverse component tree and mutate leaves's properties is really verbose. I think to have a UserControl with declarative UI model is really helpful: Professional users can construct arbitrary complex custom controls with declarative model, while all users(including professional users) can use ready-to-use controls(both built-in and custom) with straight-forward imperative model.

ndonkoHenri commented 1 year ago

🤔

FeodorFitsner commented 1 year ago

It resets scrolling position because the entire list is being re-created every time an item is add/updated/deleted.

At the moment Flet uses control's hash code to determine if the control was added or deleted within its holding collection.

As you are re-creating all the elements of the collection it's considered all new and all its elements go to a Flet client again.

For proper support of reactive approach we need to use a different algorithm to check if a control in a collection was changed. What could it be: some "dirty" flag set by a user program or maybe "deep" hash of collection item (in Python it's easy to get dict representation of object attributes)?

Your thoughts?

YouJiacheng commented 1 year ago

Can we just leverage flutter's performant rebuild system? I don't know whether on-demand data transfer is a pre-mature optimization or not. Do you have any benchmark result? Maybe for UserControl we can introduce a new concept called "State", and diff the states tree instead of controls tree? And in build, we can reference states using an abstracted states tree.

# maybe immutable assumption?
@dataclass
class MyStateA(State):
    z: tuple[int]

@dataclass
class MyStateB(State):
    x: float
    y: MyStateA

class MyUserControl(UserControl):
    def retrieve_state(self):
        return MyStateB(x=1.0, y=MyStateA(z=(1,2)))
    def new_build(self, abstract_state: MyStateB):
        # leaves of abstract_state are replaced by special proxies with indices
        # The tree is flattened and enumerated to generate indices
        # Control handle it's data properly when data is proxy
        # leaves proxies classes can be created in `State`'s __init_subclass__ hook dynamically
        # Or we can follow weakref.proxy, use .__class__ attribute to pass `isinstance` check
        # Or we can just ignore type constraint since python is dynamically typed
        return SomeControl(x=abstract_state.x, z=abstract_state.y.z)

List is hard to handle properly, but it is the core of declarative UI, I need to think more about it...

FeodorFitsner commented 1 year ago

What do you mean by "on-demand data transfer"? Right now Flet sends diffs. Per my tests sending everything every time is very slow and ineffective. I mean it's "alright" for dozens and hundreds of elements, but not alright for thousands.

Holding state inside control is a good idea, but the same question: what would be a good diffing approach?

Also, frameworks like Judo separate "data" and "template", so merging occurs on a client side. That gives you the same effectiveness as JS/Flutter frontend fetching JSON data.

ItsCubeTime commented 1 year ago

What would be the purpose of retrieve_state exactly? Is the idea that Flet would poll for changes in the return value of retrieve_state and re-run new_build every time its return value is altered?

For proper support of reactive approach we need to use a different algorithm to check if a control in a collection was changed. What could it be: some "dirty" flag set by a user program or maybe "deep" hash of collection item (in Python it's easy to get dict representation of object attributes)?

Not that Im familiar with any of the underlying code you have rn, but as you are supposedly generating JSON structures from the Control Python class instances, why not just check for differences in the resulting JSON data (comparing the previous return value with the latest) and only build UI controls that have no exact match in the previous return value (and for the sake of simplicity, you probably also want to rebuild all the children of the control that has changed) & delete any widgets that existed previously but are now missing? To identify each control in the json data, start from the top parent, see if its attributes are the same as before (excepting the list of child controls), if it is, dont re-build it, then move on to its child(ren) and do the same for them/it (and continue recursively). Once you hit a control that either didnt exist previously under the same parent or of which attributes has changed, "build" it and all the children and append it to the "old" parent that was unchanged. Then do the same process all over again but look for controls that existed in the previous return value of new_build but not in the latest and destroy any controls that seems to have gone missing. Voilá

^ I really hope that makes sense. But this should solve your example of say scrollbars resetting - unless some property on the widget owning the scrollbar is modified that is 😜

Another idea would be to let the user set an ID attribute on the control - if the new return value has a control that matches an ID in the previous, Flet will try to update the attributes on the existing control to match that of the newly created one instead of rebuilding it entirely (again, Idk if this would actually work with your codebase, but I would be imagining that you would literally just loop over every attribute on the flutter widget representing the control and set the values until they all match). This could be an "Optional" ID that if not set, Flet will fall back to the method described above. So when the user creates a control inside the new_build method, they can set a local variable on the control class instance "ID", which will get translated to the JSON representation - and then when you check for changes on the control if you find a matching ID compared to the previous JSON structure & the control parent class is the same or at least castable it will try to "update" the existing control rather than replace it entirely.

^^ Reading all this myself I must admit my suggestions sound kinda hacky 🤣But I think it could work really well & not require all too much effort to put together (again, if it plays decently well with the way Flets internals function rn)

ndonkoHenri commented 1 year ago

Another idea would be to let the user set an ID attribute on the control.

@skeledrew tried something similar in his Flet CLI(fletil): https://pypi.org/project/fletil/0.3.0/