This post is a follow-up to:
The GtkListView and GtkGridView are very useful, but are also limited in the fact that you need to put them in a GtkScrolledWindow
for them to get the performance benefits they promise by recycling list items.
However, there is a pattern that is pretty common where you need to have a header and optionally a footer in top of the widget or below it to provide additional context. Using this pattern, however, causes the said benefits to be lost because you wouldn’t be putting the widget directly as a child of a GtkScrolledWindow
. I’ll provide some examples of where this pattern is used.
Examples of the pattern
GNOME Music
While it’s true that the above examples use boxed lists, I hope you can get the idea.
To help solve this in my application, I wrote some code implementing this use-case, and I’m open to feedback regarding the code and hope it can be useful to someone someday.
#!/usr/bin/env -S gjs -m
/* Partly adapted from: https://gitlab.gnome.org/GNOME/gtk/-/blob/main/gtk/gtkviewport.c#L41
* LICENCE: LGPL 2+
* Copyright (C) 1995-1997 Peter Mattis, Spencer Kimball and Josh MacDonald
* Copyright (C) 1997-2000 The GTK+ Team
* Copyright (C) 2024 Angelo Verlain
*/
import GObject from "gi://GObject";
import Gtk from "gi://Gtk?version=4.0";
import Gdk from "gi://Gdk?version=4.0";
import GLib from "gi://GLib";
import Adw from "gi://Adw";
function init_oriented_pair(initial_value, initial_value2) {
return {
0: initial_value,
1: initial_value2 || initial_value,
};
}
function get_opposite_orientation(orientation) {
return orientation === Gtk.Orientation.HORIZONTAL
? Gtk.Orientation.VERTICAL
: Gtk.Orientation.HORIZONTAL;
}
function is_scrollable(widget) {
return "hadjustment" in widget && "vadjustment" in widget;
}
class AnnotatedView extends Gtk.Widget {
static {
GObject.registerClass(
{
GTypeName: "AnnotatedView",
Implements: [Gtk.Buildable, Gtk.Scrollable],
Properties: {
header: GObject.param_spec_object(
"header",
"Header",
"The header widget",
Gtk.Widget.$gtype,
GObject.ParamFlags.READWRITE
),
footer: GObject.param_spec_object(
"footer",
"Footer",
"The footer widget",
Gtk.Widget.$gtype,
GObject.ParamFlags.READWRITE
),
child: GObject.param_spec_object(
"child",
"child",
"The Child widget",
Gtk.Scrollable.$gtype,
GObject.ParamFlags.READWRITE
),
hadjustment: GObject.param_spec_object(
"hadjustment",
"Hadjustment",
"Horizontal Adjustment",
Gtk.Adjustment.$gtype,
GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY
),
vadjustment: GObject.param_spec_object(
"vadjustment",
"Vadjustment",
"Vertical Adjustment",
Gtk.Adjustment.$gtype,
GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY
),
vscroll_policy: GObject.param_spec_enum(
"vscroll-policy",
"VScroll-Policy",
"Vertical Scroll Policy",
Gtk.ScrollablePolicy.$gtype,
Gtk.ScrollablePolicy.MINIMUM,
GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY
),
hscroll_policy: GObject.param_spec_enum(
"hscroll-policy",
"HScroll-Policy",
"Horizontal Scroll Policy",
Gtk.ScrollablePolicy.$gtype,
Gtk.ScrollablePolicy.MINIMUM,
GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY
),
spacing: GObject.param_spec_uint(
"spacing",
"Spacing",
"The separation between elements",
0,
GLib.MAXUINT32,
0,
GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY
),
},
},
this
);
}
constructor() {
super();
}
// children
// this widget has three children: header, footer and child
// property header
get header() {
return this._header;
}
set header(widget) {
if (widget === this._header) return;
if (this._header) {
this._header.unparent();
}
widget?.set_parent(this);
this._header = widget;
this.notify("header");
}
// property child
get child() {
return this._child;
}
set child(widget) {
if (widget === this._child) return;
if (this._child) {
this._child.unparent();
}
if (widget) {
this.setup_child(widget);
}
}
// property footer
get footer() {
return this._footer;
}
set footer(widget) {
if (widget === this._footer) return;
if (this._footer) {
this._footer.unparent();
}
widget?.set_parent(this);
this._footer = widget;
this.notify("footer");
}
vfunc_add_child(builder, child, type) {
if (!(child instanceof Gtk.Widget)) return;
if (type === "header") {
this.header = child;
} else if (type === "footer") {
this.footer = child;
} else {
this.child = child;
}
}
// implementing `Gtk.Scrollable`
// The hadjustment, vadjustment, hscroll-policy and vscroll-policy properties
// are required to implement the Gtk.Scrollable interface
// adjustments
adjustment = init_oriented_pair(new Gtk.Adjustment(), new Gtk.Adjustment());
// property: hadjustment
get hadjustment() {
return this.adjustment[Gtk.Orientation.HORIZONTAL];
}
set hadjustment(v) {
this.adjustment[Gtk.Orientation.HORIZONTAL] = v;
this.queue_allocate();
this.notify("hadjustment");
v.connect("value-changed", () => this.queue_resize());
}
// property: vadjustment
get vadjustment() {
return this.adjustment[Gtk.Orientation.VERTICAL];
}
set vadjustment(v) {
this.adjustment[Gtk.Orientation.VERTICAL] = v;
this.queue_allocate();
this.notify("vadjustment");
v.connect("value-changed", () => this.queue_resize());
}
// scroll policy
scroll_policy = init_oriented_pair(Gtk.ScrollablePolicy.MINIMUM);
// property: vscroll-policy
get vscroll_policy() {
return this.scroll_policy[Gtk.Orientation.VERTICAL];
}
set vscroll_policy(v) {
if (v === this.vscroll_policy) return;
this.scroll_policy[Gtk.Orientation.VERTICAL] = v;
this.queue_resize();
this.notify("vscroll-policy");
}
// hscroll-policy
get hscoll_policy() {
return this.scroll_policy[Gtk.Orientation.HORIZONTAL];
}
set hscoll_policy(v) {
if (v === this.hscoll_policy) return;
this.scroll_policy[Gtk.Orientation.HORIZONTAL] = v;
this.queue_resize();
this.notify("hscroll-policy");
}
// spacing
// this is the (vertical) spacing between the header, footer and child
_spacing = 0;
get spacing() {
return this._spacing;
}
set spacing(v) {
if (v === this.spacing) return;
this._spacing = v;
this.queue_resize();
this.notify("spacing");
}
// child_adjustment
// this pair of adjustments is used to controll the scrolling of the scrollable child widget
child_adjustment = init_oriented_pair(new Gtk.Adjustment(), new Gtk.Adjustment());
// attach the above child_adjustment's to the child to allow it to be scrolled by us
setup_child(child) {
if (!is_scrollable(child)) {
throw new Error("A ScrolledView child must be scrollable.");
}
child.hadjustment = this.child_adjustment[Gtk.Orientation.HORIZONTAL];
child.vadjustment = this.child_adjustment[Gtk.Orientation.VERTICAL];
this._child = child;
child.set_parent(this);
this.notify("child");
}
// render each child
vfunc_snapshot(snapshot) {
this._header && this.snapshot_child(this._header, snapshot);
this._child && this.snapshot_child(this._child, snapshot);
this._footer && this.snapshot_child(this._footer, snapshot);
}
set_adjustment_values(orientation, viewport_size, child_size) {
const adjustment = this.adjustment[orientation];
const upper = child_size;
let value = adjustment.value;
/* We clamp to the left in RTL mode */
if (
orientation === Gtk.Orientation.HORIZONTAL &&
this.get_direction() === Gtk.TextDirection.RTL
) {
const dist = adjustment.upper - value - adjustment.page_size;
value = upper - dist - viewport_size;
}
adjustment.configure(
value,
0,
upper,
viewport_size * 0.1,
viewport_size * 0.9,
viewport_size
);
}
// allocate space for each widget
vfunc_size_allocate(width, height, baseline) {
const children = [this._header, this._child, this._footer];
this.adjustment[Gtk.Orientation.VERTICAL].freeze_notify();
this.adjustment[Gtk.Orientation.HORIZONTAL].freeze_notify();
// get the total and each widget's size
const { totals, sizes } = this.get_widgets_sizes(children, width, height);
const total_spacing = this.spacing * Math.max(0, sizes.size - 1);
// update the adjustment to reflect the total widget's sizes to allow scrolling
this.set_adjustment_values(
Gtk.Orientation.HORIZONTAL,
width,
totals[Gtk.Orientation.HORIZONTAL]
);
this.set_adjustment_values(
Gtk.Orientation.VERTICAL,
height,
totals[Gtk.Orientation.VERTICAL] + total_spacing
);
let x = this.adjustment[Gtk.Orientation.HORIZONTAL].value,
y = this.adjustment[Gtk.Orientation.VERTICAL].value;
// give each child an allocation
for (const child of children) {
if (child && child.visible) {
const child_size = sizes.get(child);
if (!child_size) throw new Error("oops...");
const allocation = new Gdk.Rectangle();
allocation.width = child_size[Gtk.Orientation.HORIZONTAL];
allocation.height = child_size[Gtk.Orientation.VERTICAL];
allocation.x = -x;
allocation.y = -y;
let adjustment_value = 0,
adjustment_page_size = 0;
// scroll the child widget direct using it's scrollable interface
// this code is particularly flaky
if (child === this._child) {
const widget_height = allocation.height;
const y_offset = Math.max(0, y);
const visible_height = Math.min(
widget_height,
height + Math.min(y, 0)
);
adjustment_value = y_offset;
adjustment_page_size = visible_height;
// the maximum size of the child widget is the height of the AnnotedView widget
allocation.height = Math.min(
visible_height,
Math.max(0, widget_height - y)
);
allocation.y += y_offset;
y -= widget_height;
} else {
y -= allocation.height;
}
y -= this.spacing;
if (
// don't render items that are above the visible view
allocation.height + allocation.y < 0 ||
// don't render items that are below the visible view
allocation.y > height
) {
child.unmap();
continue;
} else {
child.map();
child.size_allocate(allocation, baseline);
}
if (child === this._child && is_scrollable(child)) {
// scroll the child widget
const child_adjustment = this.child_adjustment[1];
child_adjustment.freeze_notify();
child_adjustment.value = adjustment_value;
child_adjustment.page_size = adjustment_page_size;
child_adjustment.thaw_notify();
child.queue_resize();
}
}
}
this.adjustment[Gtk.Orientation.VERTICAL].thaw_notify();
this.adjustment[Gtk.Orientation.HORIZONTAL].thaw_notify();
}
get_widgets_sizes(widgets, max_width, max_height) {
const orientation =
this.get_request_mode() === Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH
? Gtk.Orientation.VERTICAL
: Gtk.Orientation.HORIZONTAL;
const sizes = new Map();
function getTotal(orientation) {
return Array.from(sizes.values()).reduce(
(total, size) => total + size[orientation],
0
);
}
for (const widget of widgets) {
const width =
orientation === Gtk.Orientation.HORIZONTAL
? max_width - getTotal(orientation)
: max_width;
const height =
orientation === Gtk.Orientation.VERTICAL
? max_height - getTotal(orientation)
: max_height;
const size = this.get_widget_size(widget, width, height);
if (widget) {
sizes.set(widget, size);
}
}
let totals;
if (orientation === Gtk.Orientation.VERTICAL) {
totals = init_oriented_pair(
// TODO: widght must be Max(width)
max_width,
getTotal(Gtk.Orientation.VERTICAL)
);
} else {
totals = init_oriented_pair(getTotal(Gtk.Orientation.HORIZONTAL), max_height);
}
return { totals, sizes };
}
get_widget_size(widget, max_width, max_height) {
const child_size = init_oriented_pair(0);
if (widget && widget.visible) {
child_size[Gtk.Orientation.HORIZONTAL] = max_width;
child_size[Gtk.Orientation.VERTICAL] = max_height;
const orientation =
this.get_request_mode() === Gtk.SizeRequestMode.WIDTH_FOR_HEIGHT
? Gtk.Orientation.VERTICAL
: Gtk.Orientation.HORIZONTAL;
const opposite = get_opposite_orientation(orientation);
let min, nat;
[min, nat] = widget.measure(orientation, -1);
if (this.scroll_policy[orientation] === Gtk.ScrollablePolicy.MINIMUM) {
child_size[orientation] = Math.max(child_size[orientation], min);
} else {
child_size[orientation] = Math.max(child_size[orientation], nat);
}
[min, nat] = widget.measure(opposite, child_size[orientation]);
if (this.scroll_policy[opposite] === Gtk.ScrollablePolicy.MINIMUM) {
child_size[opposite] = min;
} else {
child_size[opposite] = nat;
}
}
return child_size;
}
vfunc_get_request_mode() {
return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH;
}
}
// DEMO Application
const app = new Adw.Application({
application_id: "com.vixalien.listtest",
});
const model = Gtk.StringList.new(new Array(2000).fill("Hello, World!"));
let n = 0;
class MyListView extends Gtk.ListView {
static {
GObject.registerClass(this);
}
constructor() {
super();
const factory = Gtk.SignalListItemFactory.new();
factory.connect("setup", this.setup_cb.bind(this));
factory.connect("bind", this.bind_cb.bind(this));
this.factory = factory;
this.model = Gtk.NoSelection.new(model);
}
setup_cb(_listview, listitem) {
listitem.set_child(new Gtk.Label());
}
bind_cb(_listview, listitem) {
console.log("bind", ++n);
const label = listitem.get_child();
label.set_label(`${listitem.item.get_string()} ${listitem.position+1}`);
}
}
app.connect("activate", () => {
const win = new Adw.ApplicationWindow({
application: app,
default_width: 400,
default_height: 600,
});
const toolbarView = Adw.ToolbarView.new();
const header = Adw.HeaderBar.new();
const listview = new MyListView();
const intro = new Gtk.Label({
name: "header",
label: "Hello, World!",
css_classes: ["title-1"],
});
const outro = new Gtk.Label({
name: "footer",
label: "Bye, World!",
css_classes: ["title-1"],
});
const view = new AnnotatedView();
view.header = intro;
view.footer = outro;
view.child = listview;
const scrolled = new Gtk.ScrolledWindow();
scrolled.set_child(view);
toolbarView.add_top_bar(header);
toolbarView.set_content(scrolled);
win.set_content(toolbarView);
win.present();
});
app.run([]);
The demo:
I’m very eager to get feedback!