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
RenderObject
s vorgenommen, genauerRenderBox
es, die statt eines abstrakten Koordinatensystems ein konkretes kartesisches Koordinatensystem benutzen. -
Jedes
RenderObject
hat eine Größesize
, die durchperformLayout
gesetzt wird und danach abgefragt werden darf, wenn bei dem Methodenaufruf das FlagparentUsesSize
auftrue
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 überparentData
Layout-Informationen über das Objekt speichern kann. -
Ein
RenderObjectWidget
ist ein abstraktesWidget
, dem einRenderObject
zugeordnet ist, welches von der MethodecreateRenderObject
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
RenderObject
s jedoch persistent und veränderbar, nicht transient und unveränderbar wieWidget
s. Ändert sich dasWidget
, wirdupdateRenderObject
für das ursprünglich erzeugte Objekt aufgerufen. -
Ein
MultiChildRenderObjectWidget
ist ein abstraktesRenderObjectWidget
, 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.