Flutter - 通过CustomMultiChildLayout自定义环形布局

2,568 阅读5分钟

自定义控件 - 环形布局

背景介绍:

前两天看了B站王叔讲解CustomMultiChildLayout的视频,于是便想试着写一个自定义布局从而巩固一下知识。

Flutter框架中自带的各种布局都是矩形排列的,就搞个官方没有的环形布局吧。

先上图

为了演示效我使用了AnimatedBuilder包裹布局

你也可以不使用AnimatedBuilder,控件本身是StatelessWidget

[图1] pic_1.gif [图2] pic_2.gif [图3]

pic_3.gif

开搞之前先理清思路

  1. 环形布局一般用作Loading、环形按钮菜单这些,多数情况下Child数量固定,所以不需要类似builder的构建模式,直接用List<Widget> children

  2. 让外层父类来约束自身大小,动态计算child约束。

  3. 为了方便使用动画把排列方向、首个子元素位置、环形半径缩放比、这些参数都放在构造函数中。

动脑子什么的最讨厌了,要公式的地方直接百度

截屏2021-11-10 上午12.46.39.png

OK,子部件排列偏移值的问题解决了。那动态控制子部件大小又怎么解决呢?期初我是用圆的直径 / child总数量,简单粗暴,但做出来的效果也很糟。 其实有几个child就有几个扇形,扇形内最大圆的半径就是child的半径。 当然这种实现方式child最好是圆形, 矩形的可以随便加点padding避免出界

子部件尺寸就是扇形内最大的圆

未命名绘图.png

公式百度

截屏2021-11-10 上午12.22.16.png

先定义几个算法函数:

/// 获得子部件中心点在容器中的偏移量
///
/// * [centerPoint] 容器中心点
///
/// * [radius] 容器半径
///
/// * [which] 第几个child
///
/// * [count] 子部件总数
///
/// * [initAngle] 用来决定起始位置,建议取值范围0-360
///
/// * [direction] 用来决定排列方向 1顺时针,-1逆时针
///
Offset _getChildCenterOffset({
  required Offset centerPoint,
  required double radius,
  required int which,
  required int count,
  required double initAngle,
  required int direction,
}) {
  /// 圆心坐标(a, b)
  /// 半径: r
  /// 弧度: radian (π / 180 * 角度)
  ///
  /// 求圆上任一点为(x, y)
  /// 
  /// x = a + r * cos(radian)
  /// y = b + r * sin(radian)
  double radian = _radian(360 / count);
  double radianOffset = _radian(360 + initAngle * direction);
  double x = centerPoint.dx + radius * cos(radian * which + radianOffset);
  double y = centerPoint.dy + radius * sin(radian * which + radianOffset);
  return Offset(x, y);
}

/// 获取child半径
/// 根据扇形半径内最大圆公式计算
double _getChildRadius(double r, double a) {
  /// 夹角大于180是因为只放了一个child,因为公式无法计算钝角直接return容器半径就完了。
  if (a > 180) return r;

  /// 扇形的半径为R,扇形的圆心角为A,扇形的内切圆的半径为r。
  /// SIN(A/2)=r/(R-r)
  /// r=(R-r)*SIN(A/2)
  /// r=R*SIN(A/2)-r*SIN(A/2)
  /// r+r*SIN(A/2)=R*SIN(A/2)
  /// r=(R*SIN(A/2))/(1+SIN(A/2))
  return (r * sin(_radian(a / 2))) / (1 + sin(_radian(a / 2)));
}

/// 角度转换弧度
double _radian(double angle) {
  return pi / 180 * angle;
}

完整代码

由于没几行,就不上传git了

circle_layout.dart

// circle_layout.dart

import 'dart:math';

import 'package:flutter/material.dart';

class CircleLayout extends StatelessWidget {
  final List<Widget> children;

  /// 初始角度
  final double initAngle;

  /// 排列方向
  final bool reverse;

  /// 缩放子部件圆心到容器圆心的距离
  final double radiusRatio;

  /// 一个使子组件呈现圆状布局的Layout
  ///
  /// * [reverse] 用来控制子部件的排列方向 false表示顺时针排列 true表示逆时针排列
  ///
  /// * [initAngle] 用来来设置第一个子部件的位置 0 ~ 360之间
  ///
  /// * [radiusRatio] 用来调节子部件圆心与容器圆心的距离
  ///
  const CircleLayout({
    Key? key,
    required this.children,
    this.reverse = false,
    this.radiusRatio = 1.0,
    this.initAngle = 0,
  })  : assert(0.0 <= radiusRatio && radiusRatio <= 1.0),
        assert(0 <= initAngle && initAngle <= 360),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomMultiChildLayout(
      delegate: _RingDelegate(
          count: children.length,
          initAngle: initAngle,
          reverse: reverse,
          radiusRatio: radiusRatio),
      children: [
        for (int i = 0; i < children.length; i++)
          LayoutId(id: i, child: children[i])
      ],
    );
  }
}

class _RingDelegate extends MultiChildLayoutDelegate {
  final double initAngle;
  final bool reverse;
  final int count;
  final double radiusRatio;

  _RingDelegate({
    required this.initAngle,
    required this.reverse,
    required this.count,
    required this.radiusRatio,
  });

  @override
  void performLayout(Size size) {
    /// 中心点坐标
    Offset centralPoint = Offset(size.width / 2, size.height / 2);

    /// 容器半径参考值
    double fatherRadius = min(size.width, size.height) / 2;

    double childRadius = _getChildRadius(fatherRadius, 360 / count);

    Size constraintsSize = Size(childRadius * 2, childRadius * 2);

    /// 遍历child获取他们所需的空间,得到最宽child的宽度以及最高child的高度
    /// 用来计算一个可用半径r
    /// r = 父容器给定的半径 - 最大子部件的"半径"
    List<Size> sizeCache = [];
    double largersRadius = 0;
    for (int i = 0; i < count; i++) {
      if (!hasChild(i)) break;

      Size childSize = layoutChild(i, BoxConstraints.loose(constraintsSize));
      // 缓存所有子部件尺寸 备用
      sizeCache.add(Size.copy(childSize));

      double _radius = max(childSize.width, childSize.height) / 2;
      largersRadius = _radius > largersRadius ? _radius : largersRadius;
    }
    fatherRadius -= largersRadius;

    /// 摆放组件
    for (int i = 0; i < count; i++) {
      if (!hasChild(i)) break;

      Offset offset = _getChildCenterOffset(
          centerPoint: centralPoint,
          radius: fatherRadius * radiusRatio,
          which: i,
          count: count,
          initAngle: initAngle,
          direction: reverse ? -1 : 1);
      // 由于绘制方向是lt-rb, 为了避免绘制时超出父容器边界所以还需要去掉子控件自身的"半径"
      double cr = max(sizeCache[i].width, sizeCache[i].height) / 2;
      offset -= Offset(cr, cr);

      positionChild(i, offset);
    }
  }

  @override
  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => true;
}

/// 获得子部件中心点在容器中的偏移量
///
///  [centerPoint] 容器中心点
///
/// * [radius] 容器半径
///
/// * [which] 第几个child
///
/// * [count] 子部件总数
///
/// * [initAngle] 用来决定起始位置,建议取值范围0-360
///
/// * [direction] 用来决定排列方向 1顺时针,-1逆时针
///
Offset _getChildCenterOffset({
  required Offset centerPoint,
  required double radius,
  required int which,
  required int count,
  required double initAngle,
  required int direction,
}) {
  /// 圆心坐标(a, b)
  /// 半径: r
  /// 弧度: radian (π / 180 * 角度)
  ///
  /// 求圆上任一点为(x, y)
  /// 
  /// x = a + r * cos(radian)
  /// y = b + r * sin(radian)
  double radian = _radian(360 / count);
  double radianOffset = _radian(360 + initAngle * direction);
  double x = centerPoint.dx + radius * cos(radian * which + radianOffset);
  double y = centerPoint.dy + radius * sin(radian * which + radianOffset);
  return Offset(x, y);
}

/// 获取child半径
/// 根据扇形半径内最大圆公式计算
double _getChildRadius(double r, double a) {
  /// 大于180因为只放了一个child,因为公式无法计算钝角直接return容器半径算了。
  if (a > 180) return r;

  /// 扇形的半径为R,扇形的圆心角为A,扇形的内切圆的半径为r。
  /// SIN(A/2)=r/(R-r)
  /// r=(R-r)*SIN(A/2)
  /// r=R*SIN(A/2)-r*SIN(A/2)
  /// r+r*SIN(A/2)=R*SIN(A/2)
  /// r=(R*SIN(A/2))/(1+SIN(A/2))
  return (r * sin(_radian(a / 2))) / (1 + sin(_radian(a / 2)));
}

/// 角度转换弧度
double _radian(double angle) {
  return pi / 180 * angle;
}

home_page.dart (图1)

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

import 'annulus_layout.dart';

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    _controller = AnimationController(
      lowerBound: 0.0,
      upperBound: 1.0,
      vsync: this,
      duration: const Duration(seconds: 3),
    );
    _controller.repeat(reverse: false);
    super.initState();
  }

  Widget buildPoint({
    Color color = Colors.blue,
    double width = 50,
    double height = 50,
    BoxShape shape = BoxShape.circle,
  }) {
    return Container(
      margin: const EdgeInsets.all(2),
      alignment: Alignment.center,
      width: width,
      height: height,
      decoration: BoxDecoration(
        boxShadow: const [BoxShadow(blurRadius: 20, color: Colors.black)],
        color: color,
        shape: shape,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          color: Colors.grey[300],
          shape: BoxShape.circle,
        ),
        width: 300,
        height: 300,
        child: AnimatedBuilder(
          animation: _controller,
          builder: (_, __) {
            double _v = (2 * (_controller.value - 0.5)).abs();
            return CircleLayout(
              radiusRatio: _v,
              // initAngle: _controller.value * 360,
              children: List.generate(
                9,
                (index) => index == 8
                    ? buildPoint(width: 80, height: 80, color: Colors.red)
                    : Opacity(
                        opacity: _v, child: buildPoint(width: 80, height: 80)),
              ),
            );
          },
        ),
      ),
    );
  }

  @override
  void dispose() {
    super.dispose();
    _controller.dispose();
  }
}

图2

// ...
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          color: Colors.grey[300],
          shape: BoxShape.circle,
        ),
        width: 300,
        height: 300,
        child: AnimatedBuilder(
          animation: _controller,
          builder: (_, __) {
            double _v = (2 * (_controller.value - 0.5)).abs();
            return CircleLayout(
              // radiusRatio: _v,
              initAngle: _controller.value * 360,
              children: List.generate(
                9,
                (index) => index == 8
                    ? buildPoint(width: 80, height: 80, color: Colors.red)
                    : buildPoint(
                        width: 100, height: 40, shape: BoxShape.rectangle),
              ),
            );
          },
        ),
      ),
    );
  }
// ...

图3

// ...

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          color: Colors.grey[300],
          shape: BoxShape.circle,
        ),
        width: 300,
        height: 300,
        child: AnimatedBuilder(
          animation: _controller,
          builder: (_, __) {
            double _v = (2 * (_controller.value - 0.5)).abs();
            return CircleLayout(
              // radiusRatio: _v,
              initAngle: _controller.value * 360,
              children: List.generate(9, (index) {
                if (index == 8) {
                  return buildPoint(width: 80, height: 80, color: Colors.red);
                }
                if (index % 2 == 0) {
                  return buildPoint(
                      width: 40,
                      height: 50 * _v + 30,
                      shape: BoxShape.rectangle);
                }
                return buildPoint(
                    width: 50 * _v + 30, height: 30, shape: BoxShape.rectangle);
              }),
            );
          },
        ),
      ),
    );
  }

// ...

TODO

  • Animated版本
  • 让外层父类来约束自身大小,动态计算child约束
  • 构造函数包含 排列方向、首个子元素位置、环形半径缩放比

参考:

另外感谢 来自“FlutterCandies🍭”群聊的“大能猫|青岛|前端” 提供的帮助。