When managing large amounts of data, manual widget creation finds its limits. Not only because managing both data and UI separately is tedious, but also because performance will be a real concern.
Luckily, there’s two solutions for this in GTK:
1.
Gtk.ListView
using a factory: more performant since it reuses widgets when the list gets long
2.
Gtk.ListBox
‘s
bind_model()
: less performant, but can use boxed list styling
This blog post provides an example of a
Gtk.ListView
containing my pets, which is sorted, can be searched, and is primarily made in Blueprint.
The app starts with a plain window:
from gi.repository import Adw, Gtk
@Gtk.Template.from_resource("/app/example/Pets/window.ui")
class Window(Adw.ApplicationWindow):
"""The main window."""
__gtype_name__ = "Window"
using Gtk 4.0;
using Adw 1;
template $Window: Adw.ApplicationWindow {
title: _("Pets");
default-width: 450;
default-height: 450;
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {}
}
}
Data Object
The
Gtk.ListView
needs a data object to work with, which in this example is a pet with a name and species.
This requires a
GObject.Object
called
Pet
with those properties, and a
GObject.GEnum
called
Species
:
from gi.repository import Adw, GObject, Gtk
class Species(GObject.GEnum):
"""The species of an animal."""
NONE = 0
CAT = 1
DOG = 2
[…]
class Pet(GObject.Object):
"""Data for a pet."""
__gtype_name__ = "Pet"
name = GObject.Property(type=str)
species = GObject.Property(type=Species, default=Species.NONE)
List View
Now that there’s a data object to work with, the app needs a
Gtk.ListView
with a factory and model.
To start with, there’s a
Gtk.ListView
wrapped in a
Gtk.ScrolledWindow
to make it scrollable, using the
.navigation-sidebar
style class for padding:
content: Adw.ToolbarView {
[…]
content: ScrolledWindow {
child: ListView {
styles [
"navigation-sidebar",
]
};
};
};
Factory
The factory builds a
Gtk.ListItem
for each object in the model, and utilizes bindings to show the data in the
Gtk.ListItem
:
content: ListView {
[…]
factory: BuilderListItemFactory {
template ListItem {
child: Label {
halign: start;
label: bind template.item as <$Pet>.name;
};
}
};
};
Model
Models can be modified through nesting. The data itself can be in any
Gio.ListModel
, in this case a
Gio.ListStore
works well.
The
Gtk.ListView
expects a
Gtk.SelectionModel
because that’s how it manages its selection, so the
Gio.ListStore
is wrapped in a
Gtk.NoSelection
:
using Gtk 4.0;
using Adw 1;
using Gio 2.0;
[…]
content: ListView {
[…]
model: NoSelection {
model: Gio.ListStore {
item-type: typeof<$Pet>;
$Pet {
name: "Herman";
species: cat;
}
$Pet {
name: "Saartje";
species: dog;
}
$Pet {
name: "Sofie";
species: dog;
}
$Pet {
name: "Rex";
species: dog;
}
$Pet {
name: "Lady";
species: dog;
}
$Pet {
name: "Lieke";
species: dog;
}
$Pet {
name: "Grumpy";
species: cat;
}
};
};
};
Sorting
To easily parse the list, the pets should be sorted by both name and species.
To implement this, the
Gio.ListStore
has to be wrapped in a
Gtk.SortListModel
which has a
Gtk.MultiSorter
with two sorters, a
Gtk.NumericSorter
and a
Gtk.StringSorter
.
Both of these need an expression: the property that needs to be compared.
The
Gtk.NumericSorter
expects an integer, not a
Species
, so the app needs a helper method to convert it:
class Window(Adw.ApplicationWindow):
[…]
@Gtk.Template.Callback()
def _species_to_int(self, _obj: Any, species: Species) -> int:
return int(species)
model: NoSelection {
model: SortListModel {
sorter: MultiSorter {
NumericSorter {
expression: expr $_species_to_int(item as <$Pet>.species) as <int>;
}
StringSorter {
expression: expr item as <$Pet>.name;
}
};
model: Gio.ListStore { […] };
};
};
To learn more about closures, such as the one used in the
Gtk.NumericSorter
, consider reading
my previous blog post
.
Search
To look up pets even faster, the user should be able to search for them by both their name and species.
Filtering
First, the
Gtk.ListView
‘s model needs the logic to filter the list by name or species.
This can be done with a
Gtk.FilterListModel
which has a
Gtk.AnyFilter
with two
Gtk.StringFilter
s.
One of the
Gtk.StringFilter
s expects a string, not a
Species
, so the app needs another helper method to convert it:
class Window(Adw.ApplicationWindow):
[…]
@Gtk.Template.Callback()
def _species_to_string(self, _obj: Any, species: Species) -> str:
return species.value_nick
model: NoSelection {
model: FilterListModel {
filter: AnyFilter {
StringFilter {
expression: expr item as <$Pet>.name;
}
StringFilter {
expression: expr $_species_to_string(item as <$Pet>.species) as <string>;
}
};
model: SortListModel { […] };
};
};
Entry
To actually search with the filters, the app needs a
Gtk.SearchBar
with a
Gtk.SearchEntry
.
The
Gtk.SearchEntry
‘s
text
property needs to be bound to the
Gtk.StringFilter
s’
search
properties to filter the list on demand.
To be able to start searching by typing from anywhere in the window, the
Gtk.SearchEntry
‘s
key-capture-widget
has to be set to the window, in this case the
template
itself:
content: Adw.ToolbarView {
[…]
[top]
SearchBar {
key-capture-widget: template;
child: SearchEntry search_entry {
hexpand: true;
placeholder-text: _("Search pets");
};
}
content: ScrolledWindow {
child: ListView {
[…]
model: NoSelection {
model: FilterListModel {
filter: AnyFilter {
StringFilter {
search: bind search_entry.text;
[…]
}
StringFilter {
search: bind search_entry.text;
[…]
}
};
model: SortListModel { […] };
};
};
};
};
};
Toggle Button
The
Gtk.SearchBar
should also be toggleable with a
Gtk.ToggleButton
.
To do so, the
Gtk.SearchEntry
‘s
search-mode-enabled
property should be bidirectionally bound to the
Gtk.ToggleButton
‘s
active
property:
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {
[start]
ToggleButton search_button {
icon-name: "edit-find-symbolic";
tooltip-text: _("Search");
}
}
[top]
SearchBar {
search-mode-enabled: bind search_button.active bidirectional;
[…]
}
[…]
};
The
search_button
should also be toggleable with a shortcut, which can be added with a
Gtk.ShortcutController
:
[start]
ToggleButton search_button {
[…]
ShortcutController {
scope: managed;
Shortcut {
trigger: "<Control>f";
action: "activate";
}
}
}
Empty State
Last but not least, the view should fall back to an
Adw.StatusPage
if there are no search results.
This can be done with a closure for the
visible-child-name
property in an
Adw.ViewStack
or
Gtk.Stack
. I generally prefer an
Adw.ViewStack
due to its animation curve.
The closure takes the amount of items in the
Gtk.NoSelection
as input, and returns the correct
Adw.ViewStackPage
name:
class Window(Adw.ApplicationWindow):
[…]
@Gtk.Template.Callback()
def _get_visible_child_name(self, _obj: Any, items: int) -> str:
return "content" if items else "empty"
content: Adw.ToolbarView {
[…]
content: Adw.ViewStack {
visible-child-name: bind $_get_visible_child_name(selection_model.n-items) as <string>;
enable-transitions: true;
Adw.ViewStackPage {
name: "content";
child: ScrolledWindow {
child: ListView {
[…]
model: NoSelection selection_model { […] };
};
};
}
Adw.ViewStackPage {
name: "empty";
child: Adw.StatusPage {
icon-name: "edit-find-symbolic";
title: _("No Results Found");
description: _("Try a different search");
};
}
};
};
End Result
from typing import Any
from gi.repository import Adw, GObject, Gtk
class Species(GObject.GEnum):
"""The species of an animal."""
NONE = 0
CAT = 1
DOG = 2
@Gtk.Template.from_resource("/org/example/Pets/window.ui")
class Window(Adw.ApplicationWindow):
"""The main window."""
__gtype_name__ = "Window"
@Gtk.Template.Callback()
def _get_visible_child_name(self, _obj: Any, items: int) -> str:
return "content" if items else "empty"
@Gtk.Template.Callback()
def _species_to_string(self, _obj: Any, species: Species) -> str:
return species.value_nick
@Gtk.Template.Callback()
def _species_to_int(self, _obj: Any, species: Species) -> int:
return int(species)
class Pet(GObject.Object):
"""Data about a pet."""
__gtype_name__ = "Pet"
name = GObject.Property(type=str)
species = GObject.Property(type=Species, default=Species.NONE)
using Gtk 4.0;
using Adw 1;
using Gio 2.0;
template $Window: Adw.ApplicationWindow {
title: _("Pets");
default-width: 450;
default-height: 450;
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {
[start]
ToggleButton search_button {
icon-name: "edit-find-symbolic";
tooltip-text: _("Search");
ShortcutController {
scope: managed;
Shortcut {
trigger: "f";
action: "activate";
}
}
}
}
[top]
SearchBar {
key-capture-widget: template;
search-mode-enabled: bind search_button.active bidirectional;
child: SearchEntry search_entry {
hexpand: true;
placeholder-text: _("Search pets");
};
}
content: Adw.ViewStack {
visible-child-name: bind $_get_visible_child_name(selection_model.n-items) as ;
enable-transitions: true;
Adw.ViewStackPage {
name: "content";
child: ScrolledWindow {
child: ListView {
styles [
"navigation-sidebar",
]
factory: BuilderListItemFactory {
template ListItem {
child: Label {
halign: start;
label: bind template.item as <$Pet>.name;
};
}
};
model: NoSelection selection_model {
model: FilterListModel {
filter: AnyFilter {
StringFilter {
expression: expr item as <$Pet>.name;
search: bind search_entry.text;
}
StringFilter {
expression: expr $_species_to_string(item as <$Pet>.species) as <string>;
search: bind search_entry.text;
}
};
model: SortListModel {
sorter: MultiSorter {
NumericSorter {
expression: expr $_species_to_int(item as <$Pet>.species) as <int>;
}
StringSorter {
expression: expr item as <$Pet>.name;
}
};
model: Gio.ListStore {
item-type: typeof<$Pet>;
$Pet {
name: "Herman";
species: cat;
}
$Pet {
name: "Saartje";
species: dog;
}
$Pet {
name: "Sofie";
species: dog;
}
$Pet {
name: "Rex";
species: dog;
}
$Pet {
name: "Lady";
species: dog;
}
$Pet {
name: "Lieke";
species: dog;
}
$Pet {
name: "Grumpy";
species: cat;
}
};
};
};
};
};
};
}
Adw.ViewStackPage {
name: "empty";
child: Adw.StatusPage {
icon-name: "edit-find-symbolic";
title: _("No Results Found");
description: _("Try a different search");
};
}
};
};
}
List models are pretty complicated, but I hope that this example provides a good idea of what’s possible from Blueprint, and is a good stepping stone to learn more.
Thanks for reading!
PS: a shout out to
Markus
for guessing what I’d write about next ;)