【Flutter】SingleChildRenderObjectWidgetで六角形グリッドを完全自作!子ウィジェットを配置するカスタムUI実装【HexGrid】
はじめに
こんにちは、株式会社メンバーズ Cross Application カンパニーの田原です。
この記事は、『【Flutter】六角形グリッドUIを完全自作!RenderObjectWidgetと幾何学計算で作るハニカム構造ウィジェット【HexGrid】』の続編記事です。
前回の記事では、LeafRenderObjectWidgetを使って六角形グリッドUIをゼロから構築し、RenderObjectによる描画と幾何学計算の基礎を解説しました。
今回は、さらに一歩進み、SingleChildRenderObjectWidgetに移行することで、六角形セル内に アイコンやテキストを配置できる実用的なUI を実現します。
この記事を読むことで、次の知識が得られます:
- 子ウィジェットを扱うためのRenderObject拡張方法
- レイアウト・描画・ヒットテストを制御する具体的テクニック
- より複雑で実践的なFlutterカスタムウィジェットを構築する考え方
「Flutterで自由自在にUIを作り込みたい」「RenderObjectの仕組みを応用したい」という方に向けて、すぐに実装へ活かせる実例ベースの解説をお届けします。
六角形グリッドUIの応用実装|子ウィジェット対応版
修正前のコード(LeafRenderObjectWidget)
修正に入る前に、前回実装したLeafRenderObjectWidget
を用いた六角形ウィジェットの実装を確認します。
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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 = Colors.black,
this.selectedCellColor = 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;
}
}
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
が担っていました。しかし、LeafRenderObjectWidget
は子ウィジェットを持てないため、六角形セルの中にText
やIcon
といった他のウィジェットを配置することはできませんでした。
SingleChildRenderObjectWidgetへの移行でできること
前回の記事では、子ウィジェットを持たないLeafRenderObjectWidget
を使って六角形グリッドを実装しました。このウィジェットは、単一のUI要素を描画するのに適しています。しかし、選択されたセルにマークをつけたり、特別な情報を表示したりしたい場合、子ウィジェットを扱う仕組みが必要になります。
そこで、単一の子ウィジェットを持つことができるSingleChildRenderObjectWidgetに移行します。これは、
Center
やPadding
といったウィジェットと同様に、子ウィジェットを一つだけ受け取ってその見た目や配置をカスタマイズするためのウィジェットです。
今回の実装では、六角形グリッドのHexGridRenderObjectWidget
に、marker
という名前で子ウィジェットを受け取れるように変更します。
// 変更前(LeafRenderObjectWidget)
class HexGridRenderObjectWidget extends LeafRenderObjectWidget {
// ...
}
// 変更後(SingleChildRenderObjectWidget)
class HexGridRenderObjectWidget extends SingleChildRenderObjectWidget {
// ...
const HexGridRenderObjectWidget({
super.key,
// ...
Widget? marker,
}) : super(child: marker);
// ...
}
この変更により、HexGridRenderObjectWidget
はchild
プロパティを持つようになり、ウィジェットツリー上で子ウィジェットを保持できるようになります。
子ウィジェットのレイアウト・描画・ヒットテストの実装
HexGridRenderObjectWidget
が子ウィジェットを持つようになったら、RenderObject
側でも子ウィジェットのレイアウトや描画を処理する必要があります。
今回の実装では、RenderObjectWithChildMixinを追加することで、子ウィジェットを管理するための機能(
child
プロパティなど)を_RenderHexGrid
に付与します。
class _RenderHexGrid extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
// ...
}
このMixin
を使うことで、performLayout()
、paint()
、hitTestChildren()
といったメソッド内で子ウィジェットを操作できるようになります。
1. performLayout()
: 子ウィジェットのレイアウト処理
performLayout()
メソッドには、子ウィジェットのサイズを決定するロジックを追加します。
@override
void performLayout() {
// ...(六角形グリッド全体のレイアウト計算)
if (child != null) {
child!.layout(constraints.loosen(), parentUsesSize: true);
}
}
child!.layout()
: このメソッドを呼び出すことで、子ウィジェットに自身のレイアウトを計算するよう指示します。constraints.loosen()
: 親から受けた制約をそのまま子に渡すのではなく、loosen()を使って制約を緩和します。これにより、子ウィジェットが内容に合わせて最適なサイズを決定できるようになります。
2. paint()
: 子ウィジェットの描画処理
paint()
メソッドに、選択された六角形セルの上に子ウィジェットを描画するロジックを追加します。
@override
void paint(PaintingContext context, Offset offset) {
// ...(六角形グリッドの描画ロジック)
for (int i = 0; i < _cellCenters.length; i++) {
// ...
canvas.translate(offset.dx + center.dx, offset.dy + center.dy);
canvas.drawPath(_hexPath, isSelected ? selectedPaint : defaultPaint);
if (isSelected && child != null) {
// 子ウィジェットがある場合は、選択されたセルの上に描画する
context.paintChild(
child!,
Offset(-child!.size.width / 2, -child!.size.height / 2),
);
}
canvas.restore();
}
}
context.paintChild()
: このpaintChild()メソッドが、子ウィジェットを実際に画面に描画する役割を担います。
Offset
の計算は、子ウィジェットをセルの中心に配置するためのものです。
3. hitTestChildren()
: 子ウィジェットのタップ判定
LeafRenderObjectWidget
には不要だったhitTestChildren()メソッドをオーバーライドし、子ウィジェットがタップされたかどうかを判定するロジックを追加します。
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
if (child != null && _selectedCellIndex != null) {
final Offset center = _cellCenters[_selectedCellIndex!];
final Offset childOffset =
center - Offset(child!.size.width / 2, child!.size.height / 2);
return result.addWithPaintOffset(
offset: childOffset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
return child!.hitTest(result, position: transformed);
},
);
}
return false;
}
このメソッドは、タップ位置を子ウィジェットのローカル座標に変換し、子のhitTest()
メソッドを呼び出しています。これにより、子ウィジェットが独自のタップ判定ロジックを持つ場合でも、正しくイベントを処理できるようになります。
修正後の全体のコード(SingleChildRenderObjectWidget)
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class HexGridRenderObjectWidget extends SingleChildRenderObjectWidget {
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 = Colors.black,
this.selectedCellColor = Colors.blueAccent,
this.onCellTapped,
Widget? marker,
}) : super(child: marker);
@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;
}
}
class _RenderHexGrid extends RenderBox
with RenderObjectWithChildMixin<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();
if (child != null) {
child!.layout(constraints.loosen(), parentUsesSize: true);
}
}
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);
if (isSelected && child != null) {
// 子ウィジェットがある場合は、選択されたセルの上に描画する
context.paintChild(
child!,
Offset(-child!.size.width / 2, -child!.size.height / 2),
);
}
canvas.restore();
}
}
@override
bool hitTestSelf(Offset position) => size.contains(position);
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
if (child != null && _selectedCellIndex != null) {
final Offset center = _cellCenters[_selectedCellIndex!];
final Offset childOffset =
center - Offset(child!.size.width / 2, child!.size.height / 2);
return result.addWithPaintOffset(
offset: childOffset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
return child!.hitTest(result, position: transformed);
},
);
}
return false;
}
@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();
}
}
}
実際に動かしてみる|アイコン付きHexGridの表示
修正したHexGridRenderObjectWidget
の動作を確認します。新しく追加したmarker
プロパティにIcon
を渡して、タップしたセルにアイコンが表示されるようにします。
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,
marker: IconButton(
icon: Icon(
Icons.check,
size: _cellRadius * 1.5,
color: Colors.white,
),
onPressed: () {
// Handle marker tap
print('Marker tapped');
},
),
onCellTapped: (value) => print('Cell tapped at: $value'),
),
),
),
),
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,
),
),
],
);
}
}

まとめ|Flutterで自由度の高いUIを作るために
本記事では、既存のウィジェットでは表現が難しいUIを実現するため、SingleChildRenderObjectWidget
を活用した六角形グリッド(HexGrid)の応用実装に挑戦しました。
LeafRenderObjectWidget
からSingleChildRenderObjectWidget
へ移行することで、カスタムウィジェットが子ウィジェットを保持できるようになることを確認しました。また、RenderObject
にRenderObjectWithChildMixin
を適用し、performLayout()
、paint()
、hitTestChildren()
といったメソッドをオーバーライドすることで、子ウィジェットのレイアウト、描画、そしてヒットテストを制御する具体的な方法を学びました。
この応用編を通じて、SingleChildRenderObjectWidget
の持つ柔軟性を理解し、より複雑で実用的なUIを構築するための知識を得られたことでしょう。この記事で得た知識が、皆さんのカスタムウィジェット開発の次のステップにつながれば幸いです。
最後までお読みいただき、ありがとうございました!
この記事を書いた人
Advent Calendar!
Advent Calendar 2024
What is BEMA!?
Be Engineer, More Agile