BEMAロゴ

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

【Flutter】SingleChildRenderObjectWidgetで六角形グリッドを完全自作!子ウィジェットを配置するカスタムUI実装【HexGrid】

はじめに

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

この記事は、『【Flutter】六角形グリッドUIを完全自作!RenderObjectWidgetと幾何学計算で作るハニカム構造ウィジェット【HexGrid】Open in new tab』の続編記事です。

前回の記事では、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は子ウィジェットを持てないため、六角形セルの中にTextIconといった他のウィジェットを配置することはできませんでした。

SingleChildRenderObjectWidgetへの移行でできること

前回の記事では、子ウィジェットを持たないLeafRenderObjectWidgetを使って六角形グリッドを実装しました。このウィジェットは、単一のUI要素を描画するのに適しています。しかし、選択されたセルにマークをつけたり、特別な情報を表示したりしたい場合、子ウィジェットを扱う仕組みが必要になります。

そこで、単一の子ウィジェットを持つことができるSingleChildRenderObjectWidgetOpen in new tabに移行します。これは、CenterPaddingといったウィジェットと同様に、子ウィジェットを一つだけ受け取ってその見た目や配置をカスタマイズするためのウィジェットです。

今回の実装では、六角形グリッドのHexGridRenderObjectWidgetに、markerという名前で子ウィジェットを受け取れるように変更します。

// 変更前(LeafRenderObjectWidget)
class HexGridRenderObjectWidget extends LeafRenderObjectWidget {
  // ...
}

// 変更後(SingleChildRenderObjectWidget)
class HexGridRenderObjectWidget extends SingleChildRenderObjectWidget {
  // ...
  const HexGridRenderObjectWidget({
    super.key,
    // ...
    Widget? marker,
  }) : super(child: marker);
  // ...
}

この変更により、HexGridRenderObjectWidgetchildプロパティを持つようになり、ウィジェットツリー上で子ウィジェットを保持できるようになります。

子ウィジェットのレイアウト・描画・ヒットテストの実装

HexGridRenderObjectWidgetが子ウィジェットを持つようになったら、RenderObject側でも子ウィジェットのレイアウトや描画を処理する必要があります。

今回の実装では、RenderObjectWithChildMixinOpen in new tabを追加することで、子ウィジェットを管理するための機能(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()Open in new tabを使って制約を緩和します。これにより、子ウィジェットが内容に合わせて最適なサイズを決定できるようになります。

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()Open in new tabメソッドが、子ウィジェットを実際に画面に描画する役割を担います。
  • Offsetの計算は、子ウィジェットをセルの中心に配置するためのものです。

3. hitTestChildren(): 子ウィジェットのタップ判定

LeafRenderObjectWidgetには不要だったhitTestChildren()Open in new tabメソッドをオーバーライドし、子ウィジェットがタップされたかどうかを判定するロジックを追加します。

@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へ移行することで、カスタムウィジェットが子ウィジェットを保持できるようになることを確認しました。また、RenderObjectRenderObjectWithChildMixinを適用し、performLayout()paint()hitTestChildren()といったメソッドをオーバーライドすることで、子ウィジェットのレイアウト、描画、そしてヒットテストを制御する具体的な方法を学びました。

この応用編を通じて、SingleChildRenderObjectWidgetの持つ柔軟性を理解し、より複雑で実用的なUIを構築するための知識を得られたことでしょう。この記事で得た知識が、皆さんのカスタムウィジェット開発の次のステップにつながれば幸いです。

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

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

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

この記事を書いた人

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