环形布局的定义

如果存在一个圆 A 和若干个圆 a,圆 a 皆于圆 A 相交,圆 a 的圆心皆位于圆 A 上,且圆 a 间的 圆心距 相等。

即环形布局应当满足以下两个属性:

  1. 子 widget的中点到容器圆心的距离保持一致。
  2. 相邻子 widget 中点的间距保持一致。

根据以上性质我们可以根据数学公式计算出 圆 a 相对于圆 A 的位置 ,这是实现环形布局的关键信息。

在上面的定义中并未提及圆 a 的半径关系,实际上圆 a 的半径是可以不一致的,把圆 a 看作子元素的 外切圆,在复杂的生产环境中子元素的外切圆半径往往是不一致的,所以我们还需要确定 圆 a 的最大半径

计算子元素的位置

数学推导

要确定圆 a 相对于圆 A 的位置,首先要计算 圆心 a 相对于圆心 A 的偏移量

设圆心 A 坐标为 $ (x_0, y_0) $ 、半径为 $ r $、圆心 a 坐标为 $ (x_1, y_1) $ ,圆心 A 和圆心 a 的连线和坐标系横轴的夹角角度为 $ \theta $ 。

圆心 a 坐标 $ (x_1, y_1) $ 为圆心 A 坐标 $ (x_0, y_0) $ 加上相对坐标系轴上的偏移量。

$$ x_1 = x_0 + r \times cos(\theta) $$

$$ y_1 = y_0 + r \times sin(\theta) $$

代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// 计算圆心a相对于圆心A的偏移量
///
/// @param centerPoint 圆心A的坐标
/// @param radius 圆A的半径
/// @param count 圆a的数量
/// @param which 圆a的序号
/// @param initAngle 起始位置
/// @param direction 排列方向
Offset _getChildCenterOffset({
  Offset circleCenter,
  double radius,
  int count,
  int which,
  double firstAngle,
  int direction,
}) {
  // 扇形弧度
  double radian = _radian(360 / count);
  // 处理起始位置偏移和排列方向
  double radianOffset = _radian(firstAngle * direction);
  double x = circleCenter.dx + radius * cos(radian * which + radianOffset);
  double y = circleCenter.dy + radius * sin(radian * which + radianOffset);
  return Offset(x, y);
}

计算子元素的半径

数学推导

为了满足子元素环形排列的需要,最大子元素的外切圆上限需为 $ 90^\circ $ 扇形的 内切圆,如下图所示。

设扇形半径为 $ R $、扇形圆心角为 $ \alpha $、扇形内切圆半径为 $ r $。

最大子元素半径推导过程如下。

$$ sin(\frac{\alpha}{2}) = \frac{r}{R - r} $$

$$ r = (R - r) \times sin(\frac{\alpha}{2}) $$

$$ r = R \times sin(\frac{\alpha}{2}) - r \times sin(\frac{\alpha}{2}) $$

$$ r + r \times sin(\frac{\alpha}{2}) = R \times sin(\frac{\alpha}{2}) $$

$$ r = \frac{R \times sin(\frac{\alpha}{2})}{1 + sin(\frac{\alpha}{2})} $$

代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/// 计算圆a的半径
///
/// @param radius 圆A的半径
/// @param angle 扇形的角度
double _getChildRadius(double radius, double angle) {
  // 扇形角度大于180度,只可以放置一个。
  if (angle > 180) {
    return radius;
  }

  /// 扇形最大内切圆公式,见公式推导。
  return radius * sin(_radian(angle / 2)) / (1 + sin(_radian(angle / 2)));
}

/// 计算弧度
///
/// @param angle 角度
double _radian(double angle) {
  return pi / 180 * angle;
}

实现

我们选择使用 CustomMultiChildLayout 实现环形布局的功能,看下官网的定义。

“ CustomMultiChildLayout is appropriate when there are complex relationships between the size and positioning of multiple widgets. ”

所以用 CustomMultiChildLayout 实现再合适不过,效果如下。

完整代码已上传至 pub.dev,这里仅截取 RingLayout 的部分代码。

ring_layout: https://pub.dev/packages/ring_layout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class RingLayout extends StatelessWidget {
  final List<Widget> children;
  final double initAngle;
  final bool reverse;
  final double radiusRatio;

  const RingLayout({
    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])
      ],
    );
  }
}