Skillversitas

Data-Driven UIs

Every real screen renders data: a list of recipes, a feed, a table of results. The data-driven pattern separates the data (a list of records) from the view (the widget that renders one record) and generates the list's widgets from the data - so adding an item is a data change, not a UI edit. Kivy has no built-in "template a list" primitive the way a web framework does, so the pattern is built from the pieces you already have: a custom item widget (the custom-widgets chapter), a layout that holds the generated items, and a refresh method that rebuilds the list from the data. This chapter builds the recipe list that way, covers the refresh mechanics, and the performance rules for long lists.

The Item Widget: One Record, One Widget

from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.properties import StringProperty, ObjectProperty

class RecipeItem(BoxLayout):
    """Renders ONE recipe record; the list is many of these."""
    name = StringProperty("")
    servings = StringProperty("")
    on_open = ObjectProperty(None)   # a callback the list wires up

    def __init__(self, **kw):
        super().__init__(**kw)
        self.orientation = "horizontal"
        self.size_hint_y = None
        self.height = 60
        self.spacing = 8
        self.padding = 6

        self.add_widget(Label(id="name", size_hint_x=0.6))
        self.add_widget(Label(id="serv", size_hint_x=0.2))
        btn = Button(text="Open", size_hint_x=0.2)
        btn.bind(on_press=self._open)
        self.add_widget(btn)

        self.bind(name=lambda i, v: self.children[2].text = v)
        self.bind(servings=lambda i, v: self.children[1].text = "serves " + v)

    def _open(self, instance):
        if self.on_open:
            self.on_open(self.name)

The item widget is a fixed-shape row: a name label, a servings label, an open button, and the properties that fill them. It knows nothing about the list - it renders one record and emits an on_open callback when its button is pressed. This is the template: one class, instantiated per record. The size_hint_y: None plus fixed height is what makes it a row in a scrolling list (the layout chapter's ScrollView rule - the child's height is the sum of its rows). The callback property (on_open) is the list-to-item communication: the list sets it on each item, and the item calls it with its own data, so the item stays generic.

The List: Generate from Data

from kivy.uix.boxlayout import BoxLayout
from kivy.uix.scrollview import ScrollView

RECIPES = [
    {"name": "Tomato Soup", "servings": 4},
    {"name": "Chili", "servings": 6},
    {"name": "Ramen", "servings": 2},
]

class RecipeList(BoxLayout):
    def __init__(self, data, on_open, **kw):
        super().__init__(**kw)
        self.orientation = "vertical"
        self._data = data
        self._on_open = on_open
        self._scroll = ScrollView()
        self._container = BoxLayout(orientation="vertical",
                                    size_hint_y=None)
        self._container.bind(height=lambda *a: setattr(
            self._container, "height",
            self._container.minimum_height))
        self._scroll.add_widget(self._container)
        self.add_widget(self._scroll)
        self.refresh()

    def refresh(self):
        # the whole list is a function of the data: rebuild it
        self._container.clear_widgets()
        for rec in self._data:
            item = RecipeItem(name=rec["name"],
                              servings=rec["servings"],
                              on_open=self._on_open)
            self._container.add_widget(item)

    def add(self, rec):
        self._data.append(rec)
        self.refresh()      # a data change is a UI change, for free

The list holds the data and a container, and refresh() is the function that turns the data into widgets: clear the container, instantiate a RecipeItem per record, add them. add() appends to the data and calls refresh - the data change is the UI change, which is the whole point of the pattern. The ScrollView plus the size_hint_y: None container (sized to minimum_height, the layouts chapter's scroll rule) makes the list scroll when it exceeds the viewport. The rebuild-on-change is simple and correct, and it is fine for lists up to a few hundred rows; the performance section covers the longer lists where a full rebuild on every change gets expensive.

Refresh Mechanics and the Data Source

import json, os

class RecipeStore:
    """The data lives here, not in the UI."""
    def __init__(self, path):
        self.path = path
        self.data = self._load()

    def _load(self):
        if os.path.exists(self.path):
            with open(self.path) as f:
                return json.load(f)
        return []

    def save(self):
        with open(self.path, "w") as f:
            json.dump(self.data, f)

    def add(self, rec):
        self.data.append(rec)
        self.save()

# the app wires the store to the list:
# store.add(new_rec); list.refresh()
# the UI is a VIEW over the store's data - the store is the source of truth

The pattern scales by separating the store from the view: a RecipeStore owns the data (loaded from a file, the storage chapter's territory) and the list renders it. A change goes store -> data -> refresh, and the UI never holds the data as its own truth - it is a projection of the store's. This is the same "data is the source of truth, the screen is a view" rule from the screens chapter, applied to the list. When the data comes from a network call (the networking chapter) or a database, the store is the seam where the async result lands and triggers the refresh - the UI code is identical whether the data is local or remote, because it only ever sees the store's list.

Performance: Long Lists

  • Rebuild in batches. A full refresh() over thousands of rows on every keystroke stutters; throttle the trigger (a Clock debounce from the threading chapter) so the rebuild runs at most a few times a second, not once per event.
  • Recycle, don't recreate. For very long lists, keep a fixed pool of item widgets and re-bind their properties as the visible window scrolls (the manual version of a virtualized list) instead of building and destroying a widget per row.
  • Keep the item cheap. A row with two labels and a button is fine; a row that loads an image per item multiplies the cost - lazy-load the images (bind the source on show, not on build).
  • Paginate at the data layer. If the source is a query, fetch a page at a time and append; the list never holds more than the working set.

The rebuild-everything approach is the correct starting point and the honest one - it is trivially correct because the list is a pure function of the data. The optimizations are for when the list outgrows it: throttle the trigger, recycle the widgets, keep the item light, and paginate the source. The rule that keeps it sane at every size: the data is the source of truth, the list is a view, and a data change is a UI change through refresh - the optimizations change how the view is rebuilt, not that relationship.

Summary

The data-driven list is three pieces: an item widget that renders one record (a fixed-shape row with properties and an on_open callback), a list that holds the data and a scrolling container, and a refresh() that rebuilds the items from the data - so add() is a data append plus a refresh, and the UI never holds the truth. Put the data in a store (file, network, database) and the list is a view over it, the same "data is truth, screen is a view" rule as navigation. Start with the simple rebuild (correct and fine for modest lists) and add the performance moves - throttled triggers, widget recycling, light items, paginated sources - only when the list outgrows it. The pattern is what turns a hard-coded set of labels into a screen that renders whatever the data says, which is the difference between a demo and an app.