GridView expandable sections


I’m trying to show my data in a grid like appearance, and being able to group them by an arbitrary category, so I tried both FlowBox and GridView. The group title would be an Expaander with the FlowBox/GridView as a child.

With FlowBox, I could have the exact result I wanted however I hit performance issues when there is more than 500 items (or 10,000 if I don’t load images).

With GridView, I couldn’t achieve the result I want since I have to put them inside a ScrolledWindow and each GridView would have his own scrollbar. So in the end, there would be one scrollbar for the Window with the list of groups, and a scrollbar for each gridview. I heard about the scroll policy GTK_POLICY_EXTERNAL but it just hided the scrollbar, didn’t merge with the parent one. How would it be possible to have a list of gridview with a single scrollbar (if ever possible)?

I’m, not sure if it’s the correct approach since it would then require lots of event handling to support multi selection across the grids, arrow navigation, etc.

I initially asked for guidance on the following reddit post and was redirected here and thanks to the help of u/Netblock.

Any help would be highly appreciated. I’m stuck on this for a while and can’t find solutions.

Here is the current testing in workbench

using Gtk 4.0;
Box box {}
import GObject from "gi://GObject";
import Gio from "gi://Gio";
import Gtk from "gi://Gtk";

class GamesGroupClass extends GObject.Object {

  constructor(title, games) {

    this.title = title; = games;
const GamesGroup = GObject.registerClass(
    GTypeName: "GamesGroup",

class GameClass extends GObject.Object {

  constructor(title) {
    this.title = title;
const Game = GObject.registerClass(
    GTypeName: "Game",

function generateModels() {
  const groups = [];

  const startCharCode = "A".charCodeAt(0);
  const endCharCode = "Z".charCodeAt(0);

  for (let charCode = startCharCode; charCode <= endCharCode; charCode++) {
    const title = String.fromCharCode(charCode);
    const items = [];
    for (let i = 1; i <= 1000; i++) {
      items.push(new Game(`Item ${title}${i}`));
    const itemsModel = new Gio.ListStore();
    itemsModel.splice(0, 0, items);

    const gamesGroup = new GamesGroup(title, itemsModel);

  const model = new Gio.ListStore();
  model.splice(0, 0, groups);

  return model;

const model = generateModels();
const noSelectionModel = new Gtk.NoSelection({ model: model });
const listView = new Gtk.ListView();
const listFactory = new Gtk.SignalListItemFactory();
listFactory.connect("setup", (_, listItem) => {
  const gridView = new Gtk.GridView();
  const gridFactory = new Gtk.SignalListItemFactory();
  gridFactory.connect("setup", (_, listItem) => {
    const box = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL });
    box.append(new Gtk.Picture({ height_request: 300, width_request: 200 }));
    box.append(new Gtk.Label());
    listItem.child = box;
  gridFactory.connect("bind", (_, listItem) => {
    const game = listItem.item;
    const box = listItem.child;
    const picture = box.get_first_child();
    const label = box.get_last_child();
    label.label = game.title;
  gridView.factory = gridFactory;

  const expander = new Gtk.Expander({
    child: new Gtk.ScrolledWindow({
      child: gridView,
      // If I didn't give a height, it would be like 20px height
      height_request: 600,
    expanded: true,
  listItem.child = expander;
listFactory.connect("bind", (_, listItem) => {
  const gamesGroup = listItem.item;
  const expander = listItem.child;
  expander.label = gamesGroup.title;

  const scrolledWindow = expander.child;
  const gridView = scrolledWindow.child;
  gridView.model = new Gtk.MultiSelection({ model: });

  // Here it would load the image async (and cancel it in the unbind)
listView.factory = listFactory;
listView.model = noSelectionModel;

const box = workbench.builder.get_object("box");
  new Gtk.ScrolledWindow({
    child: listView,
    hexpand: true,
    halign: Gtk.Align.FILL,