Skip to content

Instantly share code, notes, and snippets.

@sma
Created March 14, 2024 14:17
Show Gist options
  • Save sma/e60ea0dd207583af9816770c877986f5 to your computer and use it in GitHub Desktop.
Save sma/e60ea0dd207583af9816770c877986f5 to your computer and use it in GitHub Desktop.
How to build a 2d scrollable hex map

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.

  1. Ich muss eine Unterklasse von TwoDimensionalScrollView erstellen, die minimal ein delegate 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.

  2. Ich muss hier eine Methode buildViewport implementieren, die ein Exemplar einer Unterklasse von TwoDimensionalViewport 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,
        );
      }
  3. Jetzt kann ich HexMapViewport als Unterklasse von TwoDimensionalViewport implementieren. Der Viewport beschreibt den sichtbaren Ausschnitt, abhängig von den beiden Achsen, für die ich jeweils einen ScrollContainer 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,
      });
    
      ...
    }
  4. Dieser muss eine Methode createRenderObject implementieren, wo ich eine Unterklasse von RenderTwoDimensionalViewport 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,
        );
      }
  5. Ich sollte auch noch updateRenderObject implementieren, das aufgerufen hat, wenn sich etwas im Widget ändert und das im zugehörtigen RenderObject reflektiert werden soll. Das einzige, was ich aber nicht fest verdrahtet habe, ist der delegate:

      @override
      void updateRenderObject(BuildContext context, RenderHexMapViewport renderObject) {
        renderObject.delegate = delegate;
      }
  6. Jetzt kann ich RenderHexMapViewport als Unterklasse von RenderTwoDimensionalViewport 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,
      });
    
      ...
    }
  7. 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 den horizontalOffset und den verticalOffset 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 und padding.top zugreife, was eigentlich von der AxisDirection 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 vom TwoDimensionalChildManager. Der kümmert sich darum, möglichst die RenderObjects der Kind-Widgets 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 dem horizontalOffset und dem verticalOffset mitzuteilen, wie groß der Inhalt ist. Das ist wichtig, damit die Scrollbalken richtig skaliert werden.

  8. 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;
    
      ...
    }
  9. 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 ein Text-Widget als Platzhalter anzeigt. Nur zur Sicherheit prüfe ich auch noch mal, dass ich nicht außerhalb der Karte bin.

  10. Ich muss als zweites noch shouldRebuild implementieren, ähnlich wie es auch ein CustomPainter erfordert.

      @override
      bool shouldRebuild(covariant HexMapChildDelegate oldDelegate) {
        return oldDelegate.columns != columns || oldDelegate.rows != rows;
      }
  11. 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,
        );
      }
    }
  12. 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));
      }
    }
  13. 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.

  14. 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),
        ),
      ),
    )
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment