Wenn man einen 2D ScrollView bauen will…
Ich will eine große Hexkarte bauen. Wenn ich naiv 200x200 Hex-Felder in einem Stack anordne (weil die sich ja überlappen) und den in einen InteractiveViewer
packe, dann braucht Flutter 30 Sekunden (!) die 120.000 Widgets zu bauen.
-
Ich muss eine Unterklasse von
TwoDimensionalScrollView
erstellen, die minimal eindelegate
bekommt, der weiß, wie man Kinder baut. Dazu später mehr. Erst mal ist das alles nur boilerplate-Code:class HexMapView extends TwoDimensionalScrollView { const HexMapView({super.key, required super.delegate}) : super(diagonalDragBehavior: DiagonalDragBehavior.free); ... }
Ich muss
DiagonalDragBehavior.free
setzen, oder ich kann nicht in zwei Richtungen gleichzeitig scrollen, was sich falsch anfühlt. Allerdings müsste es jetzt auch zwei Scrollbars geben, was nicht funktioniert. -
Ich muss hier eine Methode
buildViewport
implementieren, die ein Exemplar einer Unterklasse vonTwoDimensionalViewport
erzeugt. Ich muss hier eigentlich nur wählen, dass ich von oben nach unten und links nach rechts meine Kinder darstellen will, der Rest ergibt sich von allein.@override Widget buildViewport(BuildContext context, ViewportOffset verticalOffset, ViewportOffset horizontalOffset) { return HexMapViewport( verticalOffset: verticalOffset, verticalAxisDirection: AxisDirection.down, horizontalOffset: horizontalOffset, horizontalAxisDirection: AxisDirection.right, delegate: delegate, mainAxis: mainAxis, ); }
-
Jetzt kann ich
HexMapViewport
als Unterklasse vonTwoDimensionalViewport
implementieren. Der Viewport beschreibt den sichtbaren Ausschnitt, abhängig von den beiden Achsen, für die ich jeweils einenScrollContainer
hätte definieren können, da aber alle Defaults gelassen habe.class HexMapViewport extends TwoDimensionalViewport { const HexMapViewport({ super.key, required super.verticalOffset, required super.verticalAxisDirection, required super.horizontalOffset, required super.horizontalAxisDirection, required super.delegate, required super.mainAxis, }); ... }
-
Dieser muss eine Methode
createRenderObject
implementieren, wo ich eine Unterklasse vonRenderTwoDimensionalViewport
nutze, um wieder ein Exemplar zu erzeugen. Alles ist boilerplate-Code, wobei ich den expliziten Cast so aus der knappen Dokumentation übernommen habe.@override RenderTwoDimensionalViewport createRenderObject(BuildContext context) { return RenderHexMapViewport( horizontalOffset: horizontalOffset, horizontalAxisDirection: horizontalAxisDirection, verticalOffset: verticalOffset, verticalAxisDirection: verticalAxisDirection, delegate: delegate, mainAxis: mainAxis, childManager: context as TwoDimensionalChildManager, ); }
-
Ich sollte auch noch
updateRenderObject
implementieren, das aufgerufen hat, wenn sich etwas im Widget ändert und das im zugehörtigenRenderObject
reflektiert werden soll. Das einzige, was ich aber nicht fest verdrahtet habe, ist derdelegate
:@override void updateRenderObject(BuildContext context, RenderHexMapViewport renderObject) { renderObject.delegate = delegate; }
-
Jetzt kann ich
RenderHexMapViewport
als Unterklasse vonRenderTwoDimensionalViewport
implementieren.class RenderHexMapViewport extends RenderTwoDimensionalViewport { RenderHexMapViewport({ required super.horizontalOffset, required super.horizontalAxisDirection, required super.verticalOffset, required super.verticalAxisDirection, required super.delegate, required super.mainAxis, required super.childManager, }); ... }
-
Hier muss ich eine Methode
layoutChildSequence
implementieren, welches die erste und einzige Methode ist, die auch etwas tun, nämlich so effizient wie möglich den Viewport zu füllen.Dabei ist zwingend erforderlich, wie ich per trial & error festgestellt habe, dass ich
applyContentDimensions
je einmal für denhorizontalOffset
und denverticalOffset
aufrufe. Außerdem muss ich wenigstens ein Kind erzeugen, sonst gibt es eine Exception. Das müsste eigentlich noch besser gehen.Hier erst mal der Code, dann folgt die Erklärung:
@override void layoutChildSequence() { final visibleRect = Offset.zero & viewportDimension; final maxY = (delegate as HexMapChildDelegate).rows; final maxX = (delegate as HexMapChildDelegate).columns; const hexSize = Size(82, 96); const padding = EdgeInsets.all(8); final hexConstraints = BoxConstraints.tight(hexSize); for (var y = 0; y < maxY; y++) { for (var x = 0; x < maxX; x++) { final offset = Offset( x * hexSize.width + (y.isOdd ? hexSize.width / 2 : 0) + padding.left - horizontalOffset.pixels, y * hexSize.height * 0.75 + padding.top - verticalOffset.pixels, ); if (visibleRect.intersect(offset & hexSize).isEmpty) continue; final vicinity = ChildVicinity(xIndex: x, yIndex: y); final child = buildOrObtainChildFor(vicinity); if (child == null) continue; child.layout(hexConstraints); parentDataOf(child).layoutOffset = offset; } } horizontalOffset.applyContentDimensions( 0, max(0, maxX * hexSize.width + hexSize.width / 2 - viewportDimension.width + padding.horizontal)); verticalOffset.applyContentDimensions( 0, max(0, maxY * hexSize.height * 0.75 + hexSize.height * 0.25 - viewportDimension.height + padding.vertical)); }
Zunächst bestimme ich, wie groß der sichtbare Abschnitt ist. Da ich gleich von den Koordinaten den Scroll-Offset abziehe, liegt mein sichtbarer Bereich immer bei 0,0. Genau so hätte ich auch erst im Layout den Scroll-Offset abziehen können.
Dann frage ich meinen Delegate, wie groß denn die Hex-Karte sein soll.
Außerdem weiß ich (das sollte ich eigentlich auch den Delegate fragen), wie groß ein einzelnes Hex-Feld ist. Zu beachten ist, dass ich später die Rahmen zentriert male, wodurch sie sich überlappen und jeweils auch einen halben Punkt ins negative gehen. Daher habe ich noch einen festen Abstand von 8 Punkt definiert. Zu beachten ist, dass ich auf
padding.left
undpadding.top
zugreife, was eigentlich von derAxisDirection
abhängig sein sollte.Um gleich die einzelnen Kinder layouten zu können, brauche ich auch noch
hexConstraints
, die ich einmalig definiere.Dann iteriere ich über alle möglichen Kinder, die ich anzeigen könnte. Ich berechne den Offset, den das Kind haben müsste, wenn es sichtbar wäre. Wenn es nicht sichtbar ist, überspringe ich es.
Dem Delegate übergebe ich eine
ChildVicinity
, wo ich endlich mal keine eigene Unterklasse brauche, sie aber erzeugen könnte, wenn ich noch weitere Informationen mit übergeben möchte. Ich übergebe einfach die Koordinaten.Mittels
buildOrObtainChildFor
hole ich mir ein Kind vomTwoDimensionalChildManager
. Der kümmert sich darum, möglichst dieRenderObject
s der Kind-Widget
s zu recyclen. Wenn ich keins bekomme, überspringe ich das Kind.Anderfalls muss ich jetzt
layout
aufrufen (sonst gibt es wie gesagt eine Exception) und schließlich den Offset setzen.Dann rufe ich
applyContentDimensions
auf, um demhorizontalOffset
und demverticalOffset
mitzuteilen, wie groß der Inhalt ist. Das ist wichtig, damit die Scrollbalken richtig skaliert werden. -
Für den Delegate baue ich mir eine Unterklasse von
TwoDimensionalChildDelegate
. An dieser Stelle müsste man ein echtes Modell implementieren, das mehr tut als nur eine leere Karte anzuzeigen.class HexMapChildDelegate extends TwoDimensionalChildDelegate { HexMapChildDelegate({ required this.columns, required this.rows, }); final int columns; final int rows; ... }
-
Ich muss nun
build
überschreiben:@override Widget? build(BuildContext context, covariant ChildVicinity vicinity) { if (vicinity.yIndex < 0 || vicinity.yIndex >= rows) return null; if (vicinity.xIndex < 0 || vicinity.xIndex >= columns) return null; return Hex( child: Text('${vicinity.xIndex}, ${vicinity.yIndex}'), ); }
Ich nutze ein
Hex
-Widget, das einen hexagonalen Rahmen malt und darin einText
-Widget als Platzhalter anzeigt. Nur zur Sicherheit prüfe ich auch noch mal, dass ich nicht außerhalb der Karte bin. -
Ich muss als zweites noch
shouldRebuild
implementieren, ähnlich wie es auch einCustomPainter
erfordert.@override bool shouldRebuild(covariant HexMapChildDelegate oldDelegate) { return oldDelegate.columns != columns || oldDelegate.rows != rows; }
-
Mein
Hex
-Widget ist für den ScrollView nicht weiter wichtig, aber der Vollständigkeit halber:class Hex extends StatelessWidget { const Hex({super.key, this.child}); final Widget? child; @override Widget build(Object context) { return DecoratedBox( decoration: const ShapeDecoration( shape: HexBorder( side: BorderSide( color: Colors.green, width: 1, strokeAlign: BorderSide.strokeAlignCenter, ), ), ), child: child != null ? Center(child: child) : null, ); } }
-
Jetzt noch meine Ad-hoc-Implementierung von
HexBorder
:class HexBorder extends OutlinedBorder { const HexBorder({super.side = BorderSide.none}); @override OutlinedBorder copyWith({BorderSide? side}) { return HexBorder(side: side ?? this.side); } @override Path getInnerPath(Rect rect, {TextDirection? textDirection}) { return getOuterPath(rect.inflate(side.strokeOffset), textDirection: textDirection); } @override Path getOuterPath(Rect rect, {TextDirection? textDirection}) { final path = Path(); final x = rect.left; final y = rect.top; final dx = rect.width / 2; final dy = rect.height / 4; path.moveTo(x + dx, y); path.lineTo(x + rect.width, y + dy); path.lineTo(x + rect.width, y + dy * 3); path.lineTo(x + dx, y + rect.height); path.lineTo(x, y + dy * 3); path.lineTo(x, y + dy); path.close(); return path; } @override void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { switch (side.style) { case BorderStyle.none: break; case BorderStyle.solid: final Rect adjustedRect = rect.inflate(side.strokeOffset / 2); final Path path = getOuterPath(adjustedRect, textDirection: textDirection); canvas.drawPath(path, side.toPaint()); } } @override ShapeBorder scale(double t) { return HexBorder(side: side.scale(t)); } }
-
Und endlich kann ich das neue Widget benutzen:
Scaffold( body: HexMapView( delegate: HexMapChildDelegate(rows: 1000, columns: 1000), ), )
Wie gesagt habe ich kein echtes Modell. Der Delegate ist allerdings ein
ChangeNotifier
und die Dokumentation sagt, dass dieser automatisch vom ScrollView beobachtet wird, um bei Änderungen die Kinder neu zu bauen. Darüber müsste man also effizient Änderungen an der Karte anzeigen können. Aber das habe ich noch nicht probiert. -
Will man auch einen Scrollbar immer anzeigen, kann man das Widget in ein
Scrollbar
-Widget packen. Leider sehe ich dann immer nur die Richtung, die zuletzt gescrollt wurde, nicht beide. Ich glaube, dass wird nicht unterstützt.Scaffold( body: Scrollbar( trackVisibility: true, thumbVisibility: true, HexMapView( delegate: HexMapChildDelegate(rows: 1000, columns: 1000), ), ), )