BEMAロゴ

エンジニアの
成長を支援する
技術メディア

【Flutter】六角形グリッドUIを完全自作!RenderObjectWidgetと幾何学計算で作るハニカム構造ウィジェット【HexGrid】

はじめに

こんにちは、株式会社メンバーズ Cross ApplicationOpen in new tab カンパニーの田原です。

FlutterでUIを開発する際、私たちは通常、ContainerColumnRowといった標準のウィジェットを組み合わせて画面を構築します。これらのウィジェットは非常に便利で強力ですが、より複雑なレイアウトや、独自の描画ロジックが求められる場面では、標準ウィジェットだけでは限界を感じることがあります。

このような時に、Flutterのレンダリングパイプラインの深部に位置するRenderObjectWidgetを利用できますRenderObjectWidgetは、ウィジェットツリーのさらに下層にあるRenderObjectという、実際の描画とレイアウトを行うオブジェクトを直接操作するための仕組みを提供します。これにより、Flutterが提供する描画の柔軟性を最大限に引き出し、既存のウィジェットでは実現困難なカスタムUIを自力で作り上げることが可能になります。

この記事では、RenderObjectWidgetを使った独自のウィジェット作成を学ぶための題材として、六角形グリッド(ハニカムグリッド、またはヘキサグリッド)のUI実装例を解説していきます。六角形グリッドは、ゲームのボードや地図表示など、多様なアプリケーションで使われる魅力的なレイアウトですが、その複雑な形状や配置を、RenderObjectWidgetの機能を用いることで実装します。

この記事で実装する六角形グリッドのデモ

この記事で学べること

  • Flutterのレンダリングの基盤となるRenderObjectWidgetの概念と役割
  • RenderObjectを介したウィジェットのレイアウトと描画ロジックの定義方法
  • プロパティの変更に対するRenderObjectの効率的な更新や、ユーザーインタラクション(タップ判定)の実装方法
  • 六角形グリッドの複雑な幾何学計算をカスタムウィジェットに落とし込む方法

RenderObjectWidgetの概要

Flutterの魅力の一つは、宣言的なUI構築です。私たちはウィジェット(Widget)を組み合わせてUIの見た目を定義しますが、実際に画面に描画されるのは、その背後にあるRenderObjectという存在です。WidgetはUIの「設計図」であり、RenderObjectこそがその「実体」として、レイアウト(サイズと位置の決定)とペイント(描画)の責任を担います。

RenderObjectWidgetの役割

通常、ContainerTextColumnのようなFlutterの標準ウィジェットは、内部で適切なRenderObjectを作成し、管理しています。しかし、これらの標準ウィジェットでは表現できない、より複雑なカスタムレイアウトや、独自の描画ロジックを必要とする場合、RenderObjectWidgetを直接実装することが有効な選択肢の一つになります。

RenderObjectWidgetは、その名の通り、RenderObjectを作成し、更新するための特別なウィジェットであり、以下の二つのメソッドを含みます。

  • createRenderObject(BuildContext context)Open in new tab

    Flutterフレームワークが、このRenderObjectWidgetに対応する新しいRenderObjectインスタンスを生成する必要があるときに呼び出されます。ここで、カスタムのRenderObjectをインスタンス化し、ウィジェットのプロパティを渡します。RenderObjectは一度作成されると、ウィジェットが再構築されても可能な限り再利用されます。

  • updateRenderObject(BuildContext context, covariant RenderObject renderObject)Open in new tab

    RenderObjectWidgetが新しいプロパティで再構築された際に呼び出されます。新しいRenderObjectを作成する代わりに、既存のRenderObjectのプロパティを更新することで、効率的なUIの変更を可能にします。このメソッド内で、RenderObjectの対応するプロパティを更新し、必要に応じてmarkNeedsLayout()markNeedsPaint()を呼び出して、Flutterに再レイアウトや再描画を促します。

RenderObjectWidgetの種類

  • LeafRenderObjectWidgetOpen in new tab

    子ウィジェットを持たないRenderObjectWidgetを作成する際に使用します。TextImageウィジェットなどがこれに該当します。

  • SingleChildRenderObjectWidgetOpen in new tab

    1つの子ウィジェットを持つRenderObjectWidgetを作成する際に使用します。PaddingAlignウィジェットなどがこれに該当します。

  • MultiChildRenderObjectWidgetOpen in new tab:

    複数の子ウィジェットを持つRenderObjectWidgetを作成する際に使用します。RowColumnウィジェットなどがこれに該当します。

  • SlottedMultiChildRenderObjectWidgetOpen in new tab

    複数の子ウィジェットを「名前付きスロット」で管理する際に使用します。例えばScaffoldウィジェットのappBarbodyのように、子ウィジェットが特定の意味や役割を持つ場所に配置されるケースで活用されます。

RenderObjectの概要

Flutterのレンダリングパイプラインの最終段階で、実際に画面上にピクセルを描画し、UI要素のサイズと位置を決定する責任を負うのがRenderObjectです。私たちが普段目にするWidgetがUIの「設計図」だとすれば、RenderObjectはその設計図に基づいて作成される「実体」であり、すべてのレイアウト計算と描画処理がここで行われます。

RenderObjectの主要な役割

RenderObjectは、主に以下の2つの重要な役割を担っています。

  1. レイアウト(Layout)

    親から受け取った制約に基づき、自身のサイズと位置を決定します。もし子RenderObjectが存在する場合は、それらに対して制約を与え、それぞれのサイズと位置も決定します。このプロセスは、親から子へ制約が渡され、子から親へサイズが報告されるという、トップダウンかつボトムアップな流れで進行します。

  2. ペイント(Paint)

    決定されたサイズと位置に基づき、自身のコンテンツを画面に描画します。このプロセスは、Canvasオブジェクトを介して様々な描画操作(図形、画像、テキストなど)を実行します。

これらの役割を果たすために、RenderObjectのサブクラス(通常はRenderBoxを継承します)では、特定のメソッドをオーバーライドする必要があります。

RenderObjectの必須メソッドとライフサイクル

RenderObjectのライフサイクルと、カスタムRenderObjectで特に重要となるメソッドを見ていきましょう。

1. performLayout()Open in new tab

RenderBoxを継承するカスタムRenderObjectで、必ずオーバーライドしなければならないのがperformLayout()メソッドです。このメソッドの主な役割は以下の2つです。

  • 自身のサイズの決定

    親から与えられたconstraints(制約、つまり利用可能な最大・最小サイズ)に基づいて、自身のサイズを決定し、sizeプロパティに設定します。このsizeは、constraints.constrain(desiredSize)のように、制約の範囲内で最適なサイズを計算して設定するのが一般的です。

  • 子ウィジェットのレイアウト(子を持つ場合)

    もしRenderObjectが子を持つ場合(例: SingleChildRenderObjectWidgetMultiChildRenderObjectWidgetが対応するRenderObject)、子のlayout()メソッドを呼び出して、子にレイアウトを指示します。この際、子に対する新たな制約を与えることもできます。

performLayout()は、markNeedsLayout()が呼び出された時、またはフレームワークがレイアウトを必要と判断した時に実行されます。効率的なUI更新のためには、プロパティの変更によってレイアウトが変わる可能性がある場合にのみ、markNeedsLayout()を呼び出すことが重要です。

2. paint()Open in new tab

RenderObjectが自身のコンテンツを実際に画面に描画する責任を負うのがpaint()メソッドです。このメソッドもまた、カスタムRenderObjectを実装する際に頻繁にオーバーライドされます。

paint()メソッドは以下の2つの引数を受け取ります。

  • PaintingContext context: 描画操作を行うためのコンテキストで、ここからCanvasオブジェクトを取得します。
  • Offset offset: 親RenderObjectから見た、このRenderObjectの描画開始位置(左上隅のオフセット)を示します。このオフセットを考慮して描画を行う必要があります。

paint()メソッド内では、context.canvasを通して様々な描画コマンドを実行します。例えば、canvas.drawPath()でパスを描画したり、canvas.drawRect()で四角形を描画したりします。paint()markNeedsPaint()が呼び出された時、またはレイアウトが変更された時(レイアウト後に再描画が必要なため)に実行されます。

3. hitTest()Open in new tab

ユーザーのジェスチャー(タップ、ドラッグなど)がどのUI要素に当たったかを判定するために使われるのがhitTest()メソッドです。これはRenderObjectのメソッドですが、RenderBoxを継承する場合は、通常はhitTestSelf()hitTestChildren()というメソッドをオーバーライドして、ヒットテストのロジックを実装します。

  • hitTest(BoxHitTestResult result, {required Offset position})

    ヒットテストの起点となるメソッドです。positionは、このRenderObjectのローカル座標系におけるタップ位置を表します。このメソッド内で、自身がヒットしたかを判定し、さらに子が存在すれば子にもヒットテストを委譲します。

    • 自身のヒット判定: size.contains(position)などで、タップ位置が自身の範囲内にあるかを判定します。
    • 子への委譲: hitTestChildren()を呼び出して子にヒットテストを委譲します。

今回の六角形グリッドのように、グリッド内の個々のセルに対するタップイベントを検知したい場合は、このhitTest()を適切に実装する必要があります。

六角形グリッドの概要

六角形グリッド、またはハニカムグリッド(Honeycomb Grid)やヘキサグリッド(Hexagon Grid, Hex Grid)とも呼ばれるこのレイアウトは、正六角形のセルが隙間なく敷き詰められた構造をしています。自然界では蜂の巣に見られるこの形状は、幾何学的に非常に効率的であり、隣接するセルの数が常に6つであるという特性から、ゲームのマップやデータ可視化、シミュレーションなど、多様な分野で活用されています。

なぜ六角形グリッドが使われるのか?

六角形グリッドには、正方形グリッドにはないいくつかの利点があります。

  • 均等な隣接性: 正方形グリッドでは、セルによって隣接するセルの数が4つ(辺で接する)だったり8つ(辺と角で接する)だったりしますが、六角形グリッドではすべてのセルが常に6つの隣接するセルを持ちます。これにより、パス探索や影響範囲の計算などがシンプルになります。
  • 等距離性: 六角形グリッドでは、中心から隣接するすべてのセルへの距離が均一になります。これはゲームの移動コスト計算などで有利に働きます。
  • 空間効率: 同じ面積を敷き詰める場合、正六角形が最も効率の良い形状の一つとされています。

六角形の基本プロパティ

六角形は、その頂点の向きによって、"pointy-top"と"flat-top"と呼ばれる二種類に分けられます。今回の実装では、頂点が上下にくるpointy-topと呼ばれる正六角形を扱います。

六角形の種類

六角形グリッドを実装する上で、まず理解しておくべきは正六角形の基本的な寸法です。ここでは、セルの中心から頂点までの距離をr (cellRadius)と定義し、これに基づいて他の寸法を導きます。

正六角形の幅と高さ

上図の通り、pointy-topの正六角形の基本プロパティは以下のようになります。

  • hexWidth): r * sqrt(3)
  • 高さhexHeight): r * 2

これらの基本プロパティの理解は、六角形グリッドの実装を理解するための基礎となります。

HexGridRenderObjectWidgetの実装

これまでのセクションで、RenderObjectWidgetRenderObjectの概念、そして六角形グリッドの基本的な特性について理解を深めました。ここからは、いよいよ実際に提供いただいたコードを基に、RenderObjectWidgetを用いたカスタムウィジェットの実装に入ります。

HexGridRenderObjectWidgetクラスの実装

まず最初に解説するのは、Flutterのウィジェットツリーに配置する、ユーザー向けのインターフェースとなるHexGridRenderObjectWidgetクラスです。

このHexGridRenderObjectWidgetは、カスタム六角形グリッドウィジェットの入り口となります。

import 'package:flutter/material.dart';

class HexGridRenderObjectWidget extends LeafRenderObjectWidget {
  final double cellRadius;
  final double cellSpacing;
  final int columns;
  final int rows;
  final Color defaultCellColor; // セルのデフォルトの色
  final Color selectedCellColor;
  final ValueChanged<Offset>? onCellTapped;

  const HexGridRenderObjectWidget({
    super.key,
    required this.cellRadius,
    required this.cellSpacing,
    required this.columns,
    required this.rows,
    this.defaultCellColor = const Colors.black,
    this.selectedCellColor = const Colors.blueAccent,
    this.onCellTapped,
  });

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderHexGrid(
      cellRadius: cellRadius,
      cellSpacing: cellSpacing,
      columns: columns,
      rows: rows,
      defaultCellColor: defaultCellColor,
      selectedCellColor: selectedCellColor,
      onCellTapped: onCellTapped,
    );
  }

  @override
  void updateRenderObject(
    BuildContext context,
    covariant _RenderHexGrid renderObject,
  ) {
    renderObject
      ..cellRadius = cellRadius
      ..cellSpacing = cellSpacing
      ..columns = columns
      ..rows = rows
      ..defaultCellColor = defaultCellColor
      ..selectedCellColor = selectedCellColor
      ..onCellTapped = onCellTapped;
  }
}

LeafRenderObjectWidgetの継承

このクラスはLeafRenderObjectWidgetを継承しています。これは、今回の六角形グリッドがそれ自身で完結する描画を行い、内部に他の子ウィジェットを持たないためです。もし、このウィジェットがさらに他のウィジェット(例えば、各六角形セルの中にTextウィジェットを配置するなど)を持つ場合は、SingleChildRenderObjectWidget(子ウィジェットが1つ)やMultiChildRenderObjectWidget(子ウィジェットが複数)を継承することを検討します。

ウィジェットのプロパティ

HexGridRenderObjectWidgetは、六角形グリッドの見た目や振る舞いを決定するいくつかのプロパティを持っています。

  • cellRadius: 各六角形セルの中心から頂点までの距離。
  • cellSpacing: 各六角形セル間の間隔。。
  • columns: グリッドの横方向(列)に表示されるセルの数。
  • rows: グリッドの縦方向(行)に表示されるセルの数です。
  • defaultCellColor: セルのデフォルトの色。
  • selectedCellColor: セルがタップされたときに表示される色。
  • onCellTapped: セルがタップされた際に呼び出されるコールバック関数。これを使って、タップされたセルの情報(この場合はそのセルの中心座標Offset)を親ウィジェットに伝えることができます。

これらのプロパティはすべてfinalとして宣言されており、HexGridRenderObjectWidgetのインスタンスが作成された後に変更されることはありません。ウィジェットのプロパティが変更されると、新しいウィジェットインスタンスが作成され、それがupdateRenderObjectメソッドを通じて既存のRenderObjectを更新する仕組みになっています。

createRenderObjectメソッド

このメソッドは、FlutterフレームワークがこのHexGridRenderObjectWidgetに対応する新しいRenderObject(ここでは_RenderHexGrid)を初めて作成する際に呼び出されます。ここで、ウィジェットのプロパティを引数として_RenderHexGridのインスタンスを生成し、返します。この_RenderHexGridが、実際に画面上でのレイアウト計算と描画を担当する「実体」となります。

updateRenderObjectメソッド

HexGridRenderObjectWidgetのプロパティが変更され、親ウィジェットによってHexGridRenderObjectWidgetが新しいプロパティで再構築された際に呼び出されます。Flutterは効率化のため、新しいRenderObjectを毎回作成するのではなく、既存のRenderObjectを可能な限り再利用しようとします。

このメソッドでは、renderObject引数として渡された既存の_RenderHexGridインスタンスに対して、新しいプロパティ値を設定しています。renderObject..cellRadius = cellRadiusのように、カスケード記法(..)を使うことで、複数のプロパティを効率的に更新できます。

ここで重要なのは、_RenderHexGridクラス内でこれらのプロパティのsetterが呼び出された際に、markNeedsLayout()markNeedsPaint()といったメソッドが適切に呼び出されるように設計されている点です。これにより、プロパティの変更に応じて_RenderHexGridが自身のレイアウトや描画を自動的に更新するようFlutterフレームワークに通知されます。

_RenderHexGridクラスの実装

HexGridRenderObjectWidgetがUIの「窓口」であるのに対し、実際のグリッドのレイアウト計算描画処理、そしてユーザーインタラクションの検知を行うのが、この_RenderHexGridクラスです。このクラスはRenderBoxを継承しており、Flutterのレンダリングパイプラインにおいて最も中心的な役割を担います。

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class _RenderHexGrid extends RenderBox {
  _RenderHexGrid({
    required double cellRadius,
    required double cellSpacing,
    required int columns,
    required int rows,
    required Color defaultCellColor,
    required Color selectedCellColor,
    ValueChanged<Offset>? onCellTapped,
  }) : _cellRadius = cellRadius,
       _cellSpacing = cellSpacing,
       _columns = math.max(1, columns), // 列数は1以上に制限
       _rows = math.max(1, rows), // 行数は1以上に制限
       _defaultCellColor = defaultCellColor,
       _selectedCellColor = selectedCellColor,
       _onCellTapped = onCellTapped {
    _updateHexPath(); // 六角形のパスを初期化
  }

  late Path _hexPath; // 六角形のパスを保持するための変数
  final List<Offset> _cellCenters = []; // 各セルの中心座標を保持するリスト
  int? _selectedCellIndex; // 選択されたセルのインデックス

  double _cellRadius;
  double get cellRadius => _cellRadius;
  set cellRadius(double value) {
    if (_cellRadius == value) return;
    _cellRadius = value;
    _updateHexPath();
    markNeedsLayout();
  }

  double _cellSpacing;
  double get cellSpacing => _cellSpacing;
  set cellSpacing(double value) {
    if (_cellSpacing == value) return;
    _cellSpacing = value;
    markNeedsLayout();
  }

  int _columns;
  int get columns => _columns;
  set columns(int value) {
    if (_columns == value) return;
    _columns = value;
    markNeedsLayout();
  }

  int _rows;
  int get rows => _rows;
  set rows(int value) {
    if (_rows == value) return;
    _rows = value;
    markNeedsLayout();
  }

  Color _defaultCellColor;
  Color get defaultCellColor => _defaultCellColor;
  set defaultCellColor(Color value) {
    if (_defaultCellColor == value) return;
    _defaultCellColor = value;
    markNeedsPaint();
  }

  Color _selectedCellColor;
  Color get selectedCellColor => _selectedCellColor;
  set selectedCellColor(Color value) {
    if (_selectedCellColor == value) return;
    _selectedCellColor = value;
    markNeedsPaint();
  }

  ValueChanged<Offset>? _onCellTapped;
  ValueChanged<Offset>? get onCellTapped => _onCellTapped;
  set onCellTapped(ValueChanged<Offset>? value) {
    if (_onCellTapped == value) return;
    _onCellTapped = value;
  }

  void _updateHexPath() {
    _hexPath = Path();
    for (int i = 0; i < 6; i++) {
      final double angle = math.pi / 3 * i; // 30度ずつ回転
      final double x = _cellRadius * math.sin(angle);
      final double y = _cellRadius * math.cos(angle);
      if (i == 0) {
        _hexPath.moveTo(x, y);
      } else {
        _hexPath.lineTo(x, y);
      }
    }
    _hexPath.close();
  }

  @override
  void performLayout() {
    final double hexWidth = cellRadius * math.sqrt(3);
    final double hexHeight = cellRadius * 2;
    final double horizontalSpacing = cellSpacing;
    final double verticalSpacing = cellSpacing * math.sqrt(3) / 2;
    final double oddRowOffset = (hexWidth + cellSpacing) / 2;
    final double hexAdditionalWidth = hexWidth + horizontalSpacing;
    final double hexAdditionalHeight = cellRadius * 1.5 + verticalSpacing;

    double totalWidth =
        hexWidth + (columns - 1) * hexAdditionalWidth;
    if (rows > 1) {
      totalWidth += oddRowOffset;
    }
    final double totalHeight =
        hexHeight + (rows - 1) * hexAdditionalHeight;

    size = constraints.constrain(Size(totalWidth, totalHeight));
    _calculateCellCenters();
  }

  void _calculateCellCenters() {
    _cellCenters.clear();
    final double hexWidth = cellRadius * math.sqrt(3);
    final double hexHeight = cellRadius * 2;
    final double horizontalSpacing = cellSpacing;
    final double verticalSpacing = cellSpacing * math.sqrt(3) / 2;
    final double hexAdditionalWidth = hexWidth + horizontalSpacing;
    final double hexAdditionalHeight = cellRadius * 1.5 + verticalSpacing;
    final double oddRowOffset = (hexWidth + cellSpacing) / 2;

    final double originX = hexWidth / 2;
    final double originY = hexHeight / 2;

    for (int row = 0; row < rows; row++) {
      for (int col = 0; col < columns; col++) {
        double xCenter = originX + col * hexAdditionalWidth;
        if (row % 2 == 1) {
          xCenter += oddRowOffset;
        }
        final double yCenter = originY + row * hexAdditionalHeight;

        _cellCenters.add(Offset(xCenter, yCenter));
      }
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    final Paint defaultPaint = Paint()..color = defaultCellColor;
    final Paint selectedPaint = Paint()..color = selectedCellColor;

    for (int i = 0; i < _cellCenters.length; i++) {
      final Offset center = _cellCenters[i];
      final bool isSelected = i == _selectedCellIndex;

      canvas.save();
      canvas.translate(offset.dx + center.dx, offset.dy + center.dy);
      canvas.drawPath(_hexPath, isSelected ? selectedPaint : defaultPaint);
      canvas.restore();
    }
  }

  @override
  bool hitTestSelf(Offset position) => size.contains(position);

  @override
  void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
    if (event is PointerDownEvent) {
      final Offset localPosition = event.localPosition;
      for (int i = 0; i < _cellCenters.length; i++) {
        final Offset center = _cellCenters[i];
        if ((localPosition - center).distance <= _cellRadius) {
          _selectedCellIndex = (_selectedCellIndex == i) ? null : i;
          _onCellTapped?.call(center);
          markNeedsPaint();
          return;
        }
      }
      _selectedCellIndex = null;
      markNeedsPaint();
    }
  }
}

_RenderHexGridクラスは、RenderBoxを継承し、以下の主要なメソッドを実装することで、六角形グリッドの描画を実現しています。

コンストラクタとプロパティの管理

class _RenderHexGrid extends RenderBox {
  _RenderHexGrid({
    required double cellRadius,
    required double cellSpacing,
    required int columns,
    required int rows,
    required Color defaultCellColor,
    required Color selectedCellColor,
    ValueChanged<Offset>? onCellTapped,
  }) : _cellRadius = cellRadius,
       _cellSpacing = cellSpacing,
       _columns = math.max(1, columns), // 列数は1以上に制限
       _rows = math.max(1, rows), // 行数は1以上に制限
       _defaultCellColor = defaultCellColor,
       _selectedCellColor = selectedCellColor,
       _onCellTapped = onCellTapped {
    _updateHexPath(); // 六角形のパスを初期化
  }

_RenderHexGridのコンストラクタでは、HexGridRenderObjectWidgetから渡されたプロパティを受け取り、それらをプライベート変数として保持します。columnsrowsmath.max(1, columns)のように1以上に制限しています。これは、グリッドの表示がゼロにならないようにするための簡単なバリデーションです。

コンストラクタによる初期化時に_updateHexPathを実行し、六角形パスを生成してキャッシュします。このキャッシュされたパスは、セルの半径プロパティ(cellRadius)が変更されるまで再利用を繰り返すことで計算コストの削減に貢献します。

  double _cellRadius;

  // getter
  double get cellRadius => _cellRadius;

  // setter 
  set cellRadius(double value) {
    if (_cellRadius == value) return;
    _cellRadius = value;
    _updateHexPath();
    markNeedsLayout();
  }

各プロパティにはgettersetterのペアが定義されています。

  • getter: 外部からプロパティの値を取得するために使われます。
  • setter: プロパティに新しい値が設定された際に、特別なロジックを実行するために使われます。

このsetterの中で、新しい値が既存の値と異なる場合にmarkNeedsLayout()またはmarkNeedsPaint()が呼び出されているのが重要です。

  • markNeedsLayout()Open in new tab

    cellRadius, cellSpacing, columns, rowsのように、グリッドのサイズや配置に影響するプロパティが変更された場合に呼び出します。これにより、Flutterフレームワークは次のフレームでperformLayout()メソッドを再実行し、グリッドのサイズとセルの位置を再計算するよう指示されます。

  • markNeedsPaint() Open in new tab

    defaultCellColor, selectedCellColorのように、グリッドの見た目のみに影響するプロパティが変更された場合に呼び出します。これにより、performLayout()をスキップしてpaint()メソッドのみを再実行し、効率的にUIを更新します。

onCellTappedsetterではmarkNeedsLayout()markNeedsPaint()も呼び出されません。これは、コールバック関数の変更がグリッドのレイアウトや描画に直接影響しないためです。

_updateHexPath(): 六角形のパス生成

  void _updateHexPath() {
    _hexPath = Path();
    for (int i = 0; i < 6; i++) {
      final double angle = math.pi / 3 * i;
      final double x = _cellRadius * math.sin(angle);
      final double y = _cellRadius * math.cos(angle);
      if (i == 0) {
        _hexPath.moveTo(x, y);
      } else {
        _hexPath.lineTo(x, y);
      }
    }
    _hexPath.close();
  }

このプライベートヘルパーメソッドは、単一の六角形を描画するためのPathオブジェクトを生成し、キャッシュ用の変数(_hexPath)に格納します。このメソッドは、_RenderHexGridの初期化時と、cellRadiusの変更によって六角形の頂点座標の再計算が必要になった時に実行します。

math.piはラジアン法を用いた角度計算で180度を表します。このmath.piを3で割ると60度(正六角形の内角)に相当します。forループ内でi倍することで、六角形の各頂点(0°, 60°, 120°, 180°, 240°, 300°)の角度を順に生成し、それぞれの座標をパスで繋いでいくことで六角形が完成します。

頂点座標は、pointy-topの正六角形の単位円上での座標にcellRadiusを乗算することで求められます。

単位円上の正六角形の頂点座標

performLayout()Open in new tab: グリッドのレイアウト計算

_RenderHexGridにおける最も重要なメソッドの一つがperformLayout()です。ここで、六角形グリッド全体のサイズと、各セルの中心座標を計算します。

  @override
  void performLayout() {
    // 六角形の基本寸法の計算
    final double hexWidth = cellRadius * math.sqrt(3);
    final double hexHeight = cellRadius * 2;

    // 六角形間の間隔を考慮した移動量
    final double horizontalSpacing = cellSpacing;
    final double verticalSpacing = cellSpacing * math.sqrt(3) / 2;

    // 隣接するセルの中心への移動距離
    final double hexAdditionalWidth = hexWidth + horizontalSpacing;
    final double hexAdditionalHeight = cellRadius * 1.5 + verticalSpacing;

    // 奇数行の六角形を水平方向に半セル分ずらすためのオフセット
    final double oddRowOffset = (hexWidth + cellSpacing) / 2;

    // グリッド全体のサイズを決定
    double totalWidth = hexWidth + (columns - 1) * hexAdditionalWidth;
    if (rows > 1) {
      totalWidth += oddRowOffset;
    }
    final double totalHeight = hexHeight + (rows - 1) * hexAdditionalHeight;

    size = constraints.constrain(Size(totalWidth, totalHeight));
    _calculateCellCenters();
  }
  1. 六角形の基本寸法の計算

    cellRadiusを基に、単一の六角形のhexWidth(幅)とhexHeight(高さ)を計算します。これは「六角形グリッドの概要」で解説した幾何学計算に基づいています。

  2. セル間の間隔の計算

    cellSpacingを考慮し、水平方向と垂直方向のセル間の間隔を定義します。特に、垂直間隔は六角形タイリングの特性上、単純なcellSpacingだけではありません。

  3. 奇数行のオフセット

    pointy-top六角形グリッドの大きな特徴として、奇数行(0から数えて1行目、3行目など)は、その前の偶数行に対して水平方向に半セル分ずれます。このずれをoddRowOffsetとして計算します。

  4. グリッド全体のサイズの決定

    columnsrowsの数、および計算された六角形の寸法と間隔を基に、グリッド全体のtotalWidthtotalHeightを算出します。最後に、親から与えられたconstraints(制約)の範囲内で最適なサイズをsizeプロパティに設定します。

  5. セルの中心座標の計算

    _calculateCellCenters()メソッドを呼び出し、描画に必要となる各六角形セルの中心座標を計算して_cellCentersリストに格納します。

ここで、隣接セルへの移動距離やオフセットの計算は以下の図のように計算されます。

六角形グリッドセルの中心間の距離
六角形グリッドセルの中心間のスペースを含めた距離

_calculateCellCenters(): 各セルの中心座標計算

_calculateCellCenters()は、performLayout()内で呼び出され、_cellCentersリストにグリッド内のすべての六角形の中心座標(Offset)を計算して格納します。

  void _calculateCellCenters() {
    _cellCenters.clear();
    // レイアウト計算で定義した値の再利用
    final double hexWidth = cellRadius * math.sqrt(3);
    final double hexHeight = cellRadius * 2;
    final double horizontalSpacing = cellSpacing;
    final double verticalSpacing = cellSpacing * math.sqrt(3) / 2;
    final double oddRowOffset = (hexWidth + cellSpacing) / 2;
    final double hexAdditionalWidth = hexWidth + horizontalSpacing;
    final double hexAdditionalHeight = cellRadius * 1.5 + verticalSpacing;

    final double originX = hexWidth / 2;
    final double originY = hexHeight / 2;

    for (int row = 0; row < rows; row++) {
      for (int col = 0; col < columns; col++) {
        double xCenter = originX + col * hexAdditionalWidth;
        // 奇数行の場合、X座標にオフセットを適用
        if (row % 2 == 1) {
          xCenter += oddRowOffset;
        }
        final double yCenter = originY + row * hexAdditionalHeight;

        _cellCenters.add(Offset(xCenter, yCenter));
      }
    }
  }
  • originXoriginYは、グリッドの最初のセル(左上隅のセル)の中心が、RenderObjectのローカル座標系(左上が(0,0))においてどこに位置するかを定義します。
  • 各セルのxCenteryCenterは、行と列のインデックスに基づき、hexAdditionalWidthhexAdditionalHeightを考慮して計算されます。特に、row % 2 == 1(奇数行)の場合にxCenteroddRowOffsetを加算するロジックが、六角形グリッドのずれを実現しています。

paint()Open in new tab: 実際の描画

  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    final Paint defaultPaint = Paint()..color = defaultCellColor;
    final Paint selectedPaint = Paint()..color = selectedCellColor;

    for (int i = 0; i < _cellCenters.length; i++) {
      final Offset center = _cellCenters[i];
      final bool isSelected = i == _selectedCellIndex;

      canvas.save();
      canvas.translate(offset.dx + center.dx, offset.dy + center.dy);
      canvas.drawPath(_hexPath, isSelected ? selectedPaint : defaultPaint);
      canvas.restore();
    }
  }

paint()メソッドは、計算されたレイアウト情報に基づいて、実際に六角形を画面に描画します。

  1. CanvasPaintオブジェクトの準備

    描画を行うためのCanvasオブジェクトと、セルの色を設定するためのPaintオブジェクトを準備します。

  2. 各セルの描画

    _cellCentersリストに格納されている各セルの中心座標をループ処理します。

  3. canvas.save()canvas.restore()

    各セルの描画の前にcanvas.save()で現在のCanvasの状態(変換行列など)を保存し、canvas.translate()で描画原点をそのセルの中心に移動させます。描画後にはcanvas.restore()で元の状態に戻すことで、他のセルの描画に影響を与えないようにします。

  4. canvas.drawPath()

    事前に_updateHexPath()で生成した六角形のパスを、現在のセルの中心を原点として描画します。isSelectedに応じて色を切り替えています。

hitTestSelf()Open in new tabhandleEvent()Open in new tab: インタラクションの処理

  @override
  bool hitTestSelf(Offset position) => size.contains(position);

  @override
  void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
    if (event is PointerDownEvent) {
      final Offset localPosition = event.localPosition;
      for (int i = 0; i < _cellCenters.length; i++) {
        final Offset center = _cellCenters[i];
        if ((localPosition - center).distance <= _cellRadius) {
          _selectedCellIndex = (_selectedCellIndex == i) ? null : i;
          _onCellTapped?.call(center);
          markNeedsPaint();
          return;
        }
      }
      _selectedCellIndex = null;
      markNeedsPaint();
    }
  }
  • hitTestSelf(Offset position)

    このメソッドは、タップイベントがこのRenderObject自身の範囲内にあるかどうかを判断します。size.contains(position)で簡単に判定できます。trueを返すと、このRenderObjectがヒットテストの対象となり、handleEvent()が呼び出される可能性があります。

  • handleEvent(PointerEvent event, covariant HitTestEntry entry)

    hitTestSelf()trueを返した場合に、ポインターイベント(タップ、ドラッグなど)がこのRenderObjectに渡されます。

    • PointerDownEvent(指が画面に触れた瞬間)の場合にのみ処理を続行します。
    • タップされたローカル座標event.localPositionを基に、_cellCentersリストをループし、どの六角形セルがタップされたかを判定します。(_cellCenters[i] - localPosition).distance <= _cellRadiusという条件で、タップ位置がセルの半径内にあるかをチェックしています。
    • セルがタップされた場合、_selectedCellIndexを更新して選択状態を切り替え、_onCellTappedコールバックを呼び出して親ウィジェットに通知します。
    • markNeedsPaint()を呼び出すことで、セルの選択状態(色)が変わったことをFlutterに知らせ、UIの再描画を促します。

動作確認

これまでに作成したHexGridRenderObjectWidgetは、cellRadiuscellSpacingといったプロパティを変更するだけで、見た目を簡単にカスタマイズできます。

ここでは、StatefulWidgetSliderウィジェットを使って、HexGridRenderObjectWidgetのプロパティを動的に変更できるデモ実装を紹介します。

以下のコードは、スライダーの値を変更すると_HexGridDemoStateStateが更新され、新しい値でHexGridRenderObjectWidgetが再構築される仕組みです。

import 'package:flutter/material.dart';
import 'package:some/hex_grid_render_object_widget.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(title: 'Hex Grid Demo', home: const HexGridPage());
  }
}

class HexGridPage extends StatefulWidget {
  const HexGridPage({super.key});

  @override
  State<HexGridPage> createState() => _HexGridPageState();
}

class _HexGridPageState extends State<HexGridPage> {
  double _cellRadius = 40.0;
  double _cellSpacing = 2.0;
  int _columns = 5;
  int _rows = 5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Hex Grid Demo')),
      body: Center(
        child: SingleChildScrollView(
          child: SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: HexGridRenderObjectWidget(
              cellRadius: _cellRadius,
              cellSpacing: _cellSpacing,
              columns: _columns,
              rows: _rows,
            ),
          ),
        ),
      ),
      bottomNavigationBar: _buildParameterControls(),
    );
  }

  Widget _buildParameterControls() {
    return Container(
      padding: const EdgeInsets.all(16.0),
      color: Colors.grey[100],
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          _buildSlider(
            title: 'Cell Radius',
            value: _cellRadius,
            min: 20.0,
            max: 100.0,
            onChanged: (value) {
              setState(() {
                _cellRadius = value;
              });
            },
          ),
          _buildSlider(
            title: 'Cell Spacing',
            value: _cellSpacing,
            min: 0.0,
            max: 10.0,
            onChanged: (value) {
              setState(() {
                _cellSpacing = value;
              });
            },
          ),
          _buildSlider(
            title: 'Columns',
            value: _columns.toDouble(),
            min: 1.0,
            max: 15.0,
            divisions: 14,
            onChanged: (value) {
              setState(() {
                _columns = value.toInt();
              });
            },
          ),
          _buildSlider(
            title: 'Rows',
            value: _rows.toDouble(),
            min: 1.0,
            max: 15.0,
            divisions: 14,
            onChanged: (value) {
              setState(() {
                _rows = value.toInt();
              });
            },
          ),
        ],
      ),
    );
  }

  Widget _buildSlider({
    required String title,
    required double value,
    required double min,
    required double max,
    int? divisions,
    required ValueChanged<double> onChanged,
  }) {
    return Row(
      children: [
        SizedBox(
          width: 100,
          child: Text('$title: ${value.toStringAsFixed(1)}'),
        ),
        Expanded(
          child: Slider(
            value: value,
            min: min,
            max: max,
            divisions: divisions,
            onChanged: onChanged,
          ),
        ),
      ],
    );
  }
}
六角形グリッドのデモ実装

まとめ

本記事では、既存のウィジェットでは表現が難しいUIを実現するために、FlutterのRenderObjectWidgetを活用した六角形グリッドの自作方法を解説しました。

UIの「設計図」であるWidgetと、実際の描画を行う「実体」であるRenderObjectの関係性を理解し、RenderObjectWidgetがその間の橋渡し役として機能することを確認しました。

この記事を通して、RenderObjectWidgetの深い理解と、それを活用したカスタムUI開発の楽しさを感じていただけたなら幸いです。既存のウィジェットの限界を超えて、より高度なUIを構築するための第一歩として、ぜひこの知識を活かしてみてください。

最後までお読みいただき、ありがとうございました!

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

田原 葉
田原 葉
2024年にメンバーズに中途で入社。前職はiOSエンジニア。現在はCross ApplicationカンパニーでFlutter技術をメインにモバイルアプリ開発支援を担当。
詳しく見る
ページトップへ戻る