【Flutter】六角形グリッドUIを完全自作!RenderObjectWidgetと幾何学計算で作るハニカム構造ウィジェット【HexGrid】
はじめに
こんにちは、株式会社メンバーズ Cross Application カンパニーの田原です。
FlutterでUIを開発する際、私たちは通常、Container
やColumn
、Row
といった標準のウィジェットを組み合わせて画面を構築します。これらのウィジェットは非常に便利で強力ですが、より複雑なレイアウトや、独自の描画ロジックが求められる場面では、標準ウィジェットだけでは限界を感じることがあります。
このような時に、Flutterのレンダリングパイプラインの深部に位置するRenderObjectWidget
を利用できます。RenderObjectWidget
は、ウィジェットツリーのさらに下層にあるRenderObject
という、実際の描画とレイアウトを行うオブジェクトを直接操作するための仕組みを提供します。これにより、Flutterが提供する描画の柔軟性を最大限に引き出し、既存のウィジェットでは実現困難なカスタムUIを自力で作り上げることが可能になります。
この記事では、RenderObjectWidget
を使った独自のウィジェット作成を学ぶための題材として、六角形グリッド(ハニカムグリッド、またはヘキサグリッド)のUI実装例を解説していきます。六角形グリッドは、ゲームのボードや地図表示など、多様なアプリケーションで使われる魅力的なレイアウトですが、その複雑な形状や配置を、RenderObjectWidget
の機能を用いることで実装します。

この記事で学べること
- Flutterのレンダリングの基盤となる
RenderObjectWidget
の概念と役割 RenderObject
を介したウィジェットのレイアウトと描画ロジックの定義方法- プロパティの変更に対する
RenderObject
の効率的な更新や、ユーザーインタラクション(タップ判定)の実装方法 - 六角形グリッドの複雑な幾何学計算をカスタムウィジェットに落とし込む方法
RenderObjectWidgetの概要
Flutterの魅力の一つは、宣言的なUI構築です。私たちはウィジェット(Widget
)を組み合わせてUIの見た目を定義しますが、実際に画面に描画されるのは、その背後にあるRenderObject
という存在です。Widget
はUIの「設計図」であり、RenderObject
こそがその「実体」として、レイアウト(サイズと位置の決定)とペイント(描画)の責任を担います。
RenderObjectWidgetの役割
通常、Container
やText
、Column
のようなFlutterの標準ウィジェットは、内部で適切なRenderObject
を作成し、管理しています。しかし、これらの標準ウィジェットでは表現できない、より複雑なカスタムレイアウトや、独自の描画ロジックを必要とする場合、RenderObjectWidget
を直接実装することが有効な選択肢の一つになります。
RenderObjectWidget
は、その名の通り、RenderObject
を作成し、更新するための特別なウィジェットであり、以下の二つのメソッドを含みます。
- createRenderObject(BuildContext context)
Flutterフレームワークが、この
RenderObjectWidget
に対応する新しいRenderObject
インスタンスを生成する必要があるときに呼び出されます。ここで、カスタムのRenderObject
をインスタンス化し、ウィジェットのプロパティを渡します。RenderObject
は一度作成されると、ウィジェットが再構築されても可能な限り再利用されます。 - updateRenderObject(BuildContext context, covariant RenderObject renderObject)
RenderObjectWidget
が新しいプロパティで再構築された際に呼び出されます。新しいRenderObject
を作成する代わりに、既存のRenderObject
のプロパティを更新することで、効率的なUIの変更を可能にします。このメソッド内で、RenderObject
の対応するプロパティを更新し、必要に応じてmarkNeedsLayout()
やmarkNeedsPaint()
を呼び出して、Flutterに再レイアウトや再描画を促します。
RenderObjectWidgetの種類
- LeafRenderObjectWidget
子ウィジェットを持たない
RenderObjectWidget
を作成する際に使用します。Text
やImage
ウィジェットなどがこれに該当します。 - SingleChildRenderObjectWidget
1つの子ウィジェットを持つ
RenderObjectWidget
を作成する際に使用します。Padding
やAlign
ウィジェットなどがこれに該当します。 - MultiChildRenderObjectWidget
:
複数の子ウィジェットを持つ
RenderObjectWidget
を作成する際に使用します。Row
やColumn
ウィジェットなどがこれに該当します。 - SlottedMultiChildRenderObjectWidget
複数の子ウィジェットを「名前付きスロット」で管理する際に使用します。例えば、
Scaffold
ウィジェットのappBar
やbody
のように、子ウィジェットが特定の意味や役割を持つ場所に配置されるケースで活用されます。
RenderObjectの概要
Flutterのレンダリングパイプラインの最終段階で、実際に画面上にピクセルを描画し、UI要素のサイズと位置を決定する責任を負うのがRenderObject
です。私たちが普段目にするWidget
がUIの「設計図」だとすれば、RenderObject
はその設計図に基づいて作成される「実体」であり、すべてのレイアウト計算と描画処理がここで行われます。
RenderObjectの主要な役割
RenderObject
は、主に以下の2つの重要な役割を担っています。
- レイアウト(Layout)
親から受け取った制約に基づき、自身のサイズと位置を決定します。もし子
RenderObject
が存在する場合は、それらに対して制約を与え、それぞれのサイズと位置も決定します。このプロセスは、親から子へ制約が渡され、子から親へサイズが報告されるという、トップダウンかつボトムアップな流れで進行します。 - ペイント(Paint)
決定されたサイズと位置に基づき、自身のコンテンツを画面に描画します。このプロセスは、
Canvas
オブジェクトを介して様々な描画操作(図形、画像、テキストなど)を実行します。
これらの役割を果たすために、RenderObject
のサブクラス(通常はRenderBox
を継承します)では、特定のメソッドをオーバーライドする必要があります。
RenderObject
の必須メソッドとライフサイクル
RenderObject
のライフサイクルと、カスタムRenderObject
で特に重要となるメソッドを見ていきましょう。
1. performLayout()
RenderBox
を継承するカスタムRenderObject
で、必ずオーバーライドしなければならないのがperformLayout()
メソッドです。このメソッドの主な役割は以下の2つです。
- 自身のサイズの決定
親から与えられた
constraints
(制約、つまり利用可能な最大・最小サイズ)に基づいて、自身のサイズを決定し、size
プロパティに設定します。このsize
は、constraints.constrain(desiredSize)
のように、制約の範囲内で最適なサイズを計算して設定するのが一般的です。 - 子ウィジェットのレイアウト(子を持つ場合)
もし
RenderObject
が子を持つ場合(例:SingleChildRenderObjectWidget
やMultiChildRenderObjectWidget
が対応するRenderObject
)、子のlayout()
メソッドを呼び出して、子にレイアウトを指示します。この際、子に対する新たな制約を与えることもできます。
performLayout()
は、markNeedsLayout()
が呼び出された時、またはフレームワークがレイアウトを必要と判断した時に実行されます。効率的なUI更新のためには、プロパティの変更によってレイアウトが変わる可能性がある場合にのみ、markNeedsLayout()
を呼び出すことが重要です。
2. paint()
RenderObject
が自身のコンテンツを実際に画面に描画する責任を負うのがpaint()
メソッドです。このメソッドもまた、カスタムRenderObject
を実装する際に頻繁にオーバーライドされます。
paint()
メソッドは以下の2つの引数を受け取ります。
PaintingContext context
: 描画操作を行うためのコンテキストで、ここからCanvas
オブジェクトを取得します。Offset offset
: 親RenderObject
から見た、このRenderObject
の描画開始位置(左上隅のオフセット)を示します。このオフセットを考慮して描画を行う必要があります。
paint()
メソッド内では、context.canvas
を通して様々な描画コマンドを実行します。例えば、canvas.drawPath()
でパスを描画したり、canvas.drawRect()
で四角形を描画したりします。paint()
はmarkNeedsPaint()
が呼び出された時、またはレイアウトが変更された時(レイアウト後に再描画が必要なため)に実行されます。
3. hitTest()
ユーザーのジェスチャー(タップ、ドラッグなど)がどの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の実装
これまでのセクションで、RenderObjectWidget
とRenderObject
の概念、そして六角形グリッドの基本的な特性について理解を深めました。ここからは、いよいよ実際に提供いただいたコードを基に、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
から渡されたプロパティを受け取り、それらをプライベート変数として保持します。columns
とrows
がmath.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();
}
各プロパティにはgetter
とsetter
のペアが定義されています。
getter
: 外部からプロパティの値を取得するために使われます。setter
: プロパティに新しい値が設定された際に、特別なロジックを実行するために使われます。
このsetter
の中で、新しい値が既存の値と異なる場合にmarkNeedsLayout()
またはmarkNeedsPaint()
が呼び出されているのが重要です。
- markNeedsLayout()
cellRadius
,cellSpacing
,columns
,rows
のように、グリッドのサイズや配置に影響するプロパティが変更された場合に呼び出します。これにより、Flutterフレームワークは次のフレームでperformLayout()
メソッドを再実行し、グリッドのサイズとセルの位置を再計算するよう指示されます。 - markNeedsPaint()
defaultCellColor
,selectedCellColor
のように、グリッドの見た目のみに影響するプロパティが変更された場合に呼び出します。これにより、performLayout()
をスキップしてpaint()
メソッドのみを再実行し、効率的にUIを更新します。
onCellTapped
のsetter
では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()
: グリッドのレイアウト計算
_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();
}
- 六角形の基本寸法の計算
cellRadius
を基に、単一の六角形のhexWidth
(幅)とhexHeight
(高さ)を計算します。これは「六角形グリッドの概要」で解説した幾何学計算に基づいています。 - セル間の間隔の計算
cellSpacing
を考慮し、水平方向と垂直方向のセル間の間隔を定義します。特に、垂直間隔は六角形タイリングの特性上、単純なcellSpacing
だけではありません。 - 奇数行のオフセット
pointy-top
六角形グリッドの大きな特徴として、奇数行(0から数えて1行目、3行目など)は、その前の偶数行に対して水平方向に半セル分ずれます。このずれをoddRowOffset
として計算します。 - グリッド全体のサイズの決定
columns
とrows
の数、および計算された六角形の寸法と間隔を基に、グリッド全体のtotalWidth
とtotalHeight
を算出します。最後に、親から与えられたconstraints
(制約)の範囲内で最適なサイズをsize
プロパティに設定します。 - セルの中心座標の計算
_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));
}
}
}
originX
とoriginY
は、グリッドの最初のセル(左上隅のセル)の中心が、RenderObject
のローカル座標系(左上が(0,0)
)においてどこに位置するかを定義します。- 各セルの
xCenter
とyCenter
は、行と列のインデックスに基づき、hexAdditionalWidth
やhexAdditionalHeight
を考慮して計算されます。特に、row % 2 == 1
(奇数行)の場合にxCenter
にoddRowOffset
を加算するロジックが、六角形グリッドのずれを実現しています。
paint()
: 実際の描画
@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()
メソッドは、計算されたレイアウト情報に基づいて、実際に六角形を画面に描画します。
Canvas
とPaint
オブジェクトの準備描画を行うための
Canvas
オブジェクトと、セルの色を設定するためのPaint
オブジェクトを準備します。- 各セルの描画
_cellCenters
リストに格納されている各セルの中心座標をループ処理します。 canvas.save()
とcanvas.restore()
各セルの描画の前に
canvas.save()
で現在のCanvas
の状態(変換行列など)を保存し、canvas.translate()
で描画原点をそのセルの中心に移動させます。描画後にはcanvas.restore()
で元の状態に戻すことで、他のセルの描画に影響を与えないようにします。canvas.drawPath()
事前に
_updateHexPath()
で生成した六角形のパスを、現在のセルの中心を原点として描画します。isSelected
に応じて色を切り替えています。
hitTestSelf()
とhandleEvent()
: インタラクションの処理
@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
は、cellRadius
やcellSpacing
といったプロパティを変更するだけで、見た目を簡単にカスタマイズできます。
ここでは、StatefulWidget
とSlider
ウィジェットを使って、HexGridRenderObjectWidget
のプロパティを動的に変更できるデモ実装を紹介します。
以下のコードは、スライダーの値を変更すると_HexGridDemoState
のState
が更新され、新しい値で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を構築するための第一歩として、ぜひこの知識を活かしてみてください。
最後までお読みいただき、ありがとうございました!
この記事を書いた人
Advent Calendar!
Advent Calendar 2024
What is BEMA!?
Be Engineer, More Agile