Flutterで要素が円軌道に並ぶListViewを実装する

circular_list

はじめに

Flutterアプリ開発で、ListViewの要素を丸く表示したい(円形の軌道上に並べたい)ことがありました。
珍しい要件だとは思いますが今後このような要件に出くわす開発者の方のためにも実装方法を残しておきます。

ポイント

  • (1) ListViewの各要素(以下、itemViewとします)を、スクロール位置に応じて、右にずらします。ScrollControllerでスクロール位置を取得し、その位置に応じて、各itemView内のmarginを調整します。
  • (2) スクロール位置が変更されたら、setState(() {})でbuild()が実行されるようにします。

前提

  • サンプルはFlutter 3.3.8で動かしました。
  • ListViewに表示する要素のサイズは固定です。
  • circular_listなどのライブラリがありますが、使っていません。理由は以下の通りです。
    • この記事では触れませんが、この要件ではpaginationが必要で、パッとみた感じこのライブラリでは、アイテムデータの末尾に次のページのアイテムデータを後から追加するようなことができなそうだったためです。
    • itemView間の垂直方向のspaceを一定にしたかったのですが、このライブラリではできなそうだったためです。

コード全体


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

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  static const _listPadding = EdgeInsets.all(16);
  static const _itemSpace = 16.0;
  static const _circularOrbitRadius = 480.0;
  static const _itemHeight = 120.0;
  static const _itemWidth = 120.0;

  late ScrollController _scrollController;
  final List<String> _items = List.generate(1000, (index) => 'Item $index');

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(() {
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
        final scrollFrameHeight = constraints.maxHeight;
        final scrollFrameCenterY = scrollFrameHeight / 2;
        return ListView.separated(
          controller: _scrollController,
          padding: _listPadding,
          itemCount: _items.length,
          itemBuilder: (BuildContext context, int i) {
            final itemCenterYInScrollContent = _listPadding.top + (_itemHeight + _itemSpace) * i + _itemHeight / 2;
            final itemCenterYInScrollFrame = itemCenterYInScrollContent - _scrollController.offset;
            final distanceBetweenItemCenterYInScrollFrameAndScrollFrameCenterY = (scrollFrameCenterY - itemCenterYInScrollFrame).abs();
            final distanceBetweenCircularOrbitCenterAndItemMaxX = sqrt(
                max(
                    0, // マイナスにならないように。
                    pow(_circularOrbitRadius, 2) - pow(distanceBetweenItemCenterYInScrollFrameAndScrollFrameCenterY, 2) // 三平方の定理を使っているだけ。
                )
            );
            final itemMarginRight = _circularOrbitRadius - distanceBetweenCircularOrbitCenterAndItemMaxX;

            return Stack(
              key: ValueKey(i),
              children: [
                Container(
                  height: _itemHeight,
                  width: double.infinity,
                  color: Colors.blue.withOpacity(0.25),
                ),
                Positioned(
                  top: 0,
                  bottom: 0,
                  right: itemMarginRight,
                  child: Container(
                    color: Colors.blue,
                    alignment: Alignment.center,
                    width: _itemWidth,
                    height: _itemHeight,
                    child: Text(
                      _items[i],
                      style: const TextStyle(
                        color: Color(0xFFFFFFFF),
                      ),
                    ),
                  ),
                ),
              ],
            );
          },
          separatorBuilder: (BuildContext context, int i) {
            return const SizedBox(height: _itemSpace);
          },
        );
      }),
    );
  }
}

解説

(1) ListViewのitemViewをスクロール位置に応じて右にずらす

  • 下図の赤字のitemMarginRightを求めます。
    • そのためには、distanceBetweenCircularOrbitCenterAndItemMaxX(長いので①とします)を求めます。これは、円のcenterXとitemViewのmaxXの距離です。(下図もご参照ください)
    • [コード中*1の部分] ①は、_circularOrbitRadiusとdistanceBetweenItemCenterYInScrollFrameAndScrollFrameCenterYで三平方の定理を使って求めます。
      • _circularOrbitRadiusは、軌道になる円の半径です。
      • distanceBetweenItemCenterYInScrollFrameAndScrollFrameCenterYは、ListViewの枠のcenterYとitemViewのcenterYの距離です。(下図もご参照ください)
  • [コード中*2の部分] 求めたitemMarginRightをPositionedのrightに指定します。

circular_list_graph

// ...省略...
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  // ...省略...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
        final scrollFrameHeight = constraints.maxHeight;
        final scrollFrameCenterY = scrollFrameHeight / 2;
        return ListView.separated(
          controller: _scrollController,
          padding: _listPadding,
          itemCount: _items.length,
          itemBuilder: (BuildContext context, int i) {
            final itemCenterYInScrollContent = _listPadding.top + (_itemHeight + _itemSpace) * i + _itemHeight / 2;
            final itemCenterYInScrollFrame = itemCenterYInScrollContent - _scrollController.offset;
            final distanceBetweenItemCenterYInScrollFrameAndScrollFrameCenterY = (scrollFrameCenterY - itemCenterYInScrollFrame).abs();

            // *1
            final distanceBetweenCircularOrbitCenterAndItemMaxX = sqrt(
                max(
                    0, // マイナスにならないように。
                    pow(_circularOrbitRadius, 2) - pow(distanceBetweenItemCenterYInScrollFrameAndScrollFrameCenterY, 2) // 三平方の定理を使っているだけ。
                )
            );
            final itemMarginRight = _circularOrbitRadius - distanceBetweenCircularOrbitCenterAndItemMaxX;

            return Stack(
              key: ValueKey(i),
              children: [
                Container(
                  height: _itemHeight,
                  width: double.infinity,
                  color: Colors.blue.withOpacity(0.25),
                ),
                Positioned(
                  top: 0,
                  bottom: 0,
                  right: itemMarginRight, // *2
                  child: Container(/*...省略...*/),
                ),
              ],
            );
          },
          separatorBuilder: (BuildContext context, int i) {
            return const SizedBox(height: _itemSpace);
          },
        );
      }),
    );
  }
}

(2) スクロール位置が変更されたらsetState(() {})でbuild()が実行されるようにする

スクロール位置が変わるたびにbuild()が実行されるよう、_scrollController.addListener()でスクロール位置の変更を監視し変更があったらsetState()を呼び出します。

// ...省略...
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  // ...省略...
  late ScrollController _scrollController;
  // ...省略...
  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(() {
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    // ...省略...
  }
}

最後に

最後までお読みくださりありがとうございます。
今回は、ListViewの各要素の位置をずらすということをしました。円形の軌道上に並べるというのは稀なケースかもしれないですが、その他の軌道上に並べたり、スクロール位置に応じて要素のサイズを調整したり(中心に近いほど大きくするなど)することに応用できると思います。読者の方の役に立てていたら嬉しいです。

コメント

タイトルとURLをコピーしました