自定义控件 - 环形布局
背景介绍:
前两天看了B站王叔讲解CustomMultiChildLayout的视频,于是便想试着写一个自定义布局从而巩固一下知识。
Flutter框架中自带的各种布局都是矩形排列的,就搞个官方没有的环形布局吧。
先上图
为了演示效我使用了AnimatedBuilder包裹布局
你也可以不使用AnimatedBuilder,控件本身是StatelessWidget
[图1]
[图2]
[图3]
开搞之前先理清思路
环形布局一般用作Loading、环形按钮菜单这些,多数情况下Child数量固定,所以不需要类似builder的构建模式,直接用
List<Widget> children。让外层父类来约束自身大小,动态计算child约束。
为了方便使用动画把排列方向、首个子元素位置、环形半径缩放比、这些参数都放在构造函数中。
动脑子什么的最讨厌了,要公式的地方直接百度
OK,子部件排列偏移值的问题解决了。那动态控制子部件大小又怎么解决呢?期初我是用圆的直径 / child总数量,简单粗暴,但做出来的效果也很糟。 其实有几个child就有几个扇形,扇形内最大圆的半径就是child的半径。
当然这种实现方式child最好是圆形, 矩形的可以随便加点padding避免出界
子部件尺寸就是扇形内最大的圆
公式百度
先定义几个算法函数:
/// 获得子部件中心点在容器中的偏移量
///
/// * [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约束
- 构造函数包含 排列方向、首个子元素位置、环形半径缩放比
参考:
-
王叔不秃 - Flutter 教程 Layout-6 CustomMultiChildLayout www.bilibili.com/video/BV1ZN…
-
已知圆心,半径,角度,求圆上的点坐标 www.jianshu.com/p/ba69d991f…
-
扇形内切圆半径公式 zhidao.baidu.com/question/32…
另外感谢 来自“FlutterCandies🍭”群聊的“大能猫|青岛|前端” 提供的帮助。