An implementation of a scrollable container with a header and footer

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

GNOME Music

Spot

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!