Schnellkontakt

Initiativbewerbungen bitte an: jobs@icnh.de

Presse und Projektanfragen: info@icnh.de

Telefon

Render Object in Flutter

Stefan Matthias Aust · 14. Okt 2020 · 6 min read

Render Object in Flutter

Was ich über Flutter gelernt habe, wenn man Widgets selbst layouten will.

Als Beispiel möchte ich ein Widget names PagedView (braucht besseren Namen!) bauen, welches nur so viele Kinder anzeigt, wie komplett in den eigenen Bereich passen und den Rest unterdrückt. Kein Kind wird also nur halb dargestellt.

  • Das Layout bei Flutter wird von RenderObjects vorgenommen, genauer RenderBoxes, die statt eines abstrakten Koordinatensystems ein konkretes kartesisches Koordinatensystem benutzen.

  • Jedes RenderObject hat eine Größe size, die durch performLayout gesetzt wird und danach abgefragt werden darf, wenn bei dem Methodenaufruf das Flag parentUsesSize auf true gesetzt ist.

  • Jede RenderBox hat zusätzlich eine minimale und maximale intrinsische Größe, die von Eltern-Widgets für das Layout herangezogen werden können.

  • RenderObjects befinden sich in einer Hierarchie, kennen ihren parent, der über parentData Layout-Informationen über das Objekt speichern kann.

  • Ein RenderObjectWidget ist ein abstraktes Widget, dem ein RenderObject zugeordnet ist, welches von der Methode createRenderObject erzeugt wird. Das Rahmenwerk ruft dann zu geeigneter Zeit diese Methode auf und baut eine zweite Hierarchie aus RenderObjects parallel zu den Widgets.

  • Im Gegensatz zu diesen sind die RenderObjects jedoch persistent und veränderbar, nicht transient und unveränderbar wie Widgets. Ändert sich das Widget, wird updateRenderObject für das ursprünglich erzeugte Objekt aufgerufen.

  • Ein MultiChildRenderObjectWidget ist ein abstraktes RenderObjectWidget, das mehrere Kinder (children) hat.

So kann meine eigene minimale Implementation aussehen:

class PagedView extends MultiChildRenderObjectWidget {
  PagedView({Key key, List<Widget> children}) 
    : super(key: key, children: children);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderPagedView();
  }
}

Und so definiere ich die zugehörige RenderBox mit passend typisierten parent data:

class RenderPagedView extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, PagedParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, PagedParentData> {
  RenderPagedView({List<RenderBox> children}) {
    addAll(children);
  }
  ...
}

class PagedParentData extends ContainerBoxParentData<RenderBox> {
}

Das ContainerRenderObjectMixin habe ich mir von RenderFlex abgeschaut. Es stellt Code zur Verfügung, um die Kinder als doppelt verkettete Liste (warum so und nicht als List erschließt sich mir nicht vollständig) zu verwalten. Es braucht die ParentData, weil dort die Verkettung mittels previousSibling und nextSibling implementiert ist, also habe ich PagedParentData passend definiert.

Das RenderBoxContainerDefaultsMixin habe ich mir auch dort abgeschaut. Es stellt u.a. defaultPaint und defaultHitTest zur Verfügung, um damit die notwendige paint- und hitTest-Methoden in RenderPagedView einfacher implementieren zu können.

Schritt 1 ist das Verknüpfen der ParentData (denn standardmäßig würde ein BoxParentData-Objekt benutzt werden, das einfach nur einen offset hat, aber nicht die Verkettung):

class RenderPagedView {
  ...

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! PagedParentData) child.parentData = PagedParentData();
  }

  ...

Dann kann ich als Schritt 2 den hitTest mit Hilfe des Defaults-Mixin implementieren:

class RenderPagedView {
  ...

  @override
  bool hitTestChildren(HitTestResult result, {Offset position}) {
    return defaultHitTestChildren(result, position: position);
  }

  ...

Die wichtige Funktion ist performLayout. Ich nenne sie Schritt 3.

In meinem Fall gehe ich die verkettete Liste der Kinder durch und bestimme von jedem die Größe basierend auf den eigenen Constraints. Ich muss das für jedes Kind machen (nicht nur die sichtbaren), sonst beschwert sich das Rahmenwerk.

Für meinen Anwendungsfall möchte die Kinder seitenweise unter einander anordnen und pflege dazu zwei Variablen firstToPaint und lastToPaint.

Am Schluss setze ich pageCount basierend auf meinen Berechnungen, um anderen mitzuteilen, auf wie viele Seiten sich die Widgets (aktuell) aufteilen.

class RenderPagedView {
  ...

  int page = 0;
  int pageCount;
  RenderBox firstToPaint;
  RenderBox lastToPaint;

  @override
  void performLayout() {
    double y = 0;
    int p = 0;

    firstToPaint = lastToPaint = null;

    RenderBox child = firstChild;
    while (child != null) {
      final PagedParentData childParentData = child.parentData;

      if (page == p && firstToPaint == null) firstToPaint = lastToPaint = child;

      final childConstraints = constraints.loosen().tighten(width: constraints.maxWidth);
      child.layout(childConstraints, parentUsesSize: true);
      childParentData.offset = Offset(0, y);
      y += child.size.height;
      if (y > constraints.maxHeight) {
        childParentData.offset = Offset(0, y = 0);
        p++;
      }

      if (page == p && firstToPaint != null) lastToPaint = child;

      child = childParentData.nextSibling;
    }

    size = Size(constraints.maxWidth, constraints.maxHeight);

    pageCount = p + 1;
  }

  ...

Der Algorithmus ist vielleicht zu umständlich, funktioniert aber auch, wenn page einen anderen Wert als 0 hat (das kommt später). Am Ende der Methode sind dann firstToPaint und lastToPaint gesetzt sowie pageCount.

Nun kann ich im vierten Schritt malen:

class RenderPagedView {
  ...

  @override
  void paint(PaintingContext context, Offset offset) {
    if (size.isEmpty) return;

    var child = firstToPaint;
    while (child != null) {
      final PagedParentData childParentData = child.parentData;
      context.paintChild(child, childParentData.offset + offset);
      if (child == lastToPaint) break;
      child = childParentData.nextSibling;
    }
  }

  ...

Um die initiale Seite page zu übergeben, muss ich die folgenden Änderungen am PagedView-Widget vornehmen. Zu beachten ist, dass ich nun auch updateRenderObject implementieren muss:

class PagedView extends MultiChildRenderObjectWidget {
  final int page;

  PagedView({Key key, this.page = 0, List<Widget> children})
      : assert(page != null),
        assert(page >= 0),
        assert(children != null),
        super(key: key, children: children);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderPagedView()..page = page;
  }

  @override
  void updateRenderObject(BuildContext context, RenderPagedView renderObject) {
    if (renderObject.page != page) {
      renderObject.page = page;
      renderObject.markNeedsLayout();
    }
  }
}

Wahrscheinlich wäre es besser, die Logik, ob ein markNeedsLayout notwendig ist, in RenderPagedView zu ziehen und nicht von außen anzuwenden. Ich muss jedenfalls den Wert explizit aktualisieren und auch einmal initial setzen. Dafür kann ich das = 0 bei der Definition der Variablen innerhalb von RenderPagedView weglassen.

Jetzt habe ich alles implementiert, was zum Selbst-Layouten notwendig war.

Denkbar wäre noch, die Layout-Richtung zu parametrisieren oder auch die Ausrichtung der Kinder, wenn der Platz nicht komplett ausgefüllt wird. Beides ändert aber nur den kleinen Teil in performLayout, wo ich aus den Größen der Kinder das y verändere.


Unabhängig von obigem Beispiel bin ich noch unsicher, wie ich denn die Anzahl der Seiten aus dem RenderObject wieder an die Widget-Ebene zurückmelde, etwa wenn ich einen "Weiter" Button zum Durchblättern haben möchte.

Ich würde wohl einen PagedController, der ein Listenable ist, bis zum RenderPagedView durchreichen und dort dann die Anzahl setzen lassen, auf das dann ein AnimatedWidget auf den Listenable lauscht und dann ggf. den "Weiter"-Button deaktiviert.