flutter 实现旋转星球

先看效果

planet_widget.dart

Dart 复制代码
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';

class PlanetWidget extends StatefulWidget {
  const PlanetWidget({Key? key, required this.children, this.minRadius = 50})
      : super(key: key);

  @override
  _PlanetWidgetState createState() => _PlanetWidgetState();

  final List<Widget> children;
  final double minRadius;
}

class _PlanetWidgetState extends State<PlanetWidget>
    with TickerProviderStateMixin {
  late AnimationController animationController;

  /// 启动加载或者重新加载的时候用的Controller
  late AnimationController reloadAnimationController;

  double preAngle = 0.0;
  double _radius = -1.0;

  List<PlanetTagInfo>? childTagList = [];

  /// 当前操作的向量信息
  Vector3 currentOperateVector = Vector3(1.0, 0.0, 0.0);

  @override
  void initState() {
    super.initState();
    animationController =
        AnimationController(lowerBound: 0, upperBound: pi * 2, vsync: this);
    reloadAnimationController = AnimationController(
        lowerBound: 0,
        upperBound: 1,
        duration: Duration(milliseconds: 300),
        vsync: this);

    animationController.addListener(() {
      setState(() {
        calTagInfo(animationController.value - preAngle);
      });
    });
    reloadAnimationController.addListener(() {
      setState(() {});
    });

    // initData();
  }

  void initData() {
    childTagList = widget.children
        .map((e) => PlanetTagInfo(child: e, planetTagPos: Vector3.zero()))
        .toList();

    currentOperateVector = updateOperateVector(Offset(-1.0, 1.0));

    initTagInfo();

    WidgetsBinding.instance!.addPostFrameCallback((_) {
      reloadAnimationController.forward().then((value) => _reStartAnimation());
    });
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (widget.children.isNotEmpty) {
      initData();
    }
  }

  @override
  void didUpdateWidget(covariant PlanetWidget oldWidget) {
    if (oldWidget.children != this.widget.children) {
      if (widget.children.isNotEmpty) {
        animationController.reset();
        reloadAnimationController.reset();
        initData();
      }
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        var radius = min(constraints.maxWidth, constraints.maxHeight) / 2.0;

        /// 太小就不显示了
        if (radius < widget.minRadius) {
          return SizedBox.shrink();
        }

        if (_radius != radius) {
          if (_radius == -1.0) {
            _radius = radius;
            initTagInfo();
          } else {
            _radius = radius;
            resizeTagInfo();
          }
        }

        final Map<Type, GestureRecognizerFactory> gestures =
            <Type, GestureRecognizerFactory>{};
        gestures[PanGestureRecognizer] =
            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
          () => PanGestureRecognizer(debugOwner: this),
          (PanGestureRecognizer instance) {
            instance
              ..onDown = (detail) {
                if (animationController.isAnimating) {
                  _stopAnimation();
                }
              }
              ..onStart = (detail) {
                if (animationController.isAnimating) {
                  _stopAnimation();
                }
              }
              ..onUpdate = (detail) {
                if (detail.delta.dx == 0 && detail.delta.dy == 0) {
                  return;
                }
                double distance = sqrt(detail.delta.dx * detail.delta.dx +
                    detail.delta.dy * detail.delta.dy);
                setState(() {
                  currentOperateVector = updateOperateVector(detail.delta);
                  calTagInfo(distance / _radius);
                });
              }
              ..onEnd = (detail) {
                startFlingAnimation(detail);
              }
              ..onCancel = () {
                _reStartAnimation();
              }
              ..dragStartBehavior = DragStartBehavior.start
              ..gestureSettings =

                  /// 为了能竞争过 HorizontalDragGestureRecognizer ,不得不使用一些下作手段;
                  /// 比如说卷起来,判断阈值比 HorizontalDragGestureRecognizer 的阈值小;
                  /// PS :默认的PanGestureRecognizer 的判断阈值是 touchSlop * 2;
                  const DeviceGestureSettings(touchSlop: kTouchSlop / 4);
          },
        );

        gestures[TapGestureRecognizer] =
            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(debugOwner: this),
          (TapGestureRecognizer instance) {
            instance
              ..onTapDown = (detail) {
                _stopAnimation();
              }
              ..onTapUp = (detail) {
                _reStartAnimation();
              };
          },
        );

        return RawGestureDetector(
          gestures: gestures,
          behavior: HitTestBehavior.translucent,
          excludeFromSemantics: false,
          child: Container(
            width: _radius * 2,
            height: _radius * 2,
            child: LayoutBuilder(
              builder: (BuildContext context, BoxConstraints constraints) {
                /// 要根据Z轴高度更新Stack中的叠放顺序;
                /// 要不然点击重叠部分的时候,可能点击事件并非最上面的处理;
                /// PS :实在不行搞个获取Z轴的Stack,修改hitTest让它遍历顺序根据Z轴来制定?
                childTagList?.sort((item1, item2) =>
                    item1.planetTagPos.z.compareTo(item2.planetTagPos.z));

                var itemOpacity =
                    ((_radius - widget.minRadius) / widget.minRadius);

                if (itemOpacity <= 0.1) {
                  return SizedBox.shrink();
                }

                return Opacity(
                  opacity: _radius >= widget.minRadius * 2 ? 1.0 : itemOpacity,
                  child: Stack(
                    alignment: Alignment.center,
                    children: childTagList
                            ?.map((e) => Transform(
                                  transform: calTransformByTagInfo(
                                      e, animationController.value),

                                  /// 聊胜于无的优化,如果基本看不到了,那没必要显示
                                  child: e.opacity >= 0.15
                                      ? Opacity(
                                          opacity: e.opacity,
                                          child: RepaintBoundary(
                                            child: e.child,
                                          ),
                                        )
                                      : SizedBox.shrink(),
                                ))
                            .toList() ??
                        [],
                  ),
                );
              },
            ),
          ),
        );
      },
    );
  }

  void _stopAnimation() {
    animationController.stop();
  }

  void _reStartAnimation() {
    animationController.value = preAngle;
    animationController.repeat(
        min: 0, max: pi * 2, period: Duration(seconds: 20));
  }

  void startFlingAnimation(DragEndDetails detail) {
    /// 计算手势要滑动多少距离
    var velocityPerDis = sqrt(pow(detail.velocity.pixelsPerSecond.dx, 2) +
        pow(detail.velocity.pixelsPerSecond.dy, 2));

    if (velocityPerDis < 5) {
      _reStartAnimation();
      return;
    }

    /// 距离处以周长就是变化的角度,最大一周
    var angle = min(
        2 * pi,
        animationController.value +
            velocityPerDis / (2 * pi * _radius) * (2 * pi));

    animationController
        .animateWith(SpringSimulation(
            SpringDescription.withDampingRatio(
              mass: 1.0,
              stiffness: 500.0,
            ),
            animationController.value,
            angle,
            1)
          ..tolerance = Tolerance(
            velocity: double.infinity,
            distance: 0.01,
          ))
        .then((value) => _reStartAnimation());
  }

  @override
  void dispose() {
    animationController.dispose();
    reloadAnimationController.dispose();
    super.dispose();
  }

  /// 设置Tag们的初始位置
  void initTagInfo() {
    final itemCount = childTagList?.length ?? 0;

    for (var index = 1; index < itemCount + 1; index++) {
      final phi = (acos(-1.0 + (2.0 * index - 1.0) / itemCount));
      final theta = sqrt(itemCount * pi) * phi;

      final x = _radius * cos(theta) * sin(phi);
      final y = _radius * sin(theta) * sin(phi);
      final z = _radius * cos(phi);

      var childItem = childTagList?[index - 1];
      childItem?.planetTagPos = Vector3(x, y, z);
      childItem?.currentAngle = phi;
      childItem?.radius = _radius;
    }
  }

  /// 重新根据当前的半径,修改大小
  void resizeTagInfo() {
    final itemCount = childTagList?.length ?? 0;

    for (var index = 0; index < itemCount; index++) {
      var childItem = childTagList![index];
      var pos = childItem.planetTagPos;
      pos.x = (_radius / childItem.radius) * pos.x;
      pos.y = (_radius / childItem.radius) * pos.y;
      pos.z = (_radius / childItem.radius) * pos.z;

      childItem.radius = _radius;
    }
  }

  /// 根据变化的角度计算最新位置
  void calTagInfo(double dAngle) {
    var currentAngle = preAngle + dAngle;

    final itemCount = childTagList?.length ?? 0;

    for (var index = 1; index < itemCount + 1; index++) {
      var childItem = childTagList![index - 1];

      var point = childItem.planetTagPos;

      double x = cos(dAngle) * point.x +
          (1 - cos(dAngle)) *
              (currentOperateVector.x * point.x +
                  currentOperateVector.y * point.y) *
              currentOperateVector.x +
          sin(dAngle) * (currentOperateVector.y * point.z);

      double y = cos(dAngle) * point.y +
          (1 - cos(dAngle)) *
              (currentOperateVector.x * point.x +
                  currentOperateVector.y * point.y) *
              currentOperateVector.y -
          sin(dAngle) * (currentOperateVector.x * point.z);

      double z = cos(dAngle) * point.z +
          sin(dAngle) *
              (currentOperateVector.x * point.y -
                  currentOperateVector.y * point.x);
      if (x.isNaN || y.isNaN || z.isNaN) {
        continue;
      }

      childItem.planetTagPos = Vector3(x, y, z);
      childItem.currentAngle = currentAngle;
    }

    if (animationController.isAnimating) {
      preAngle = currentAngle;
    }
  }

  Vector3 updateOperateVector(Offset operateOffset) {
    double x = -operateOffset.dy;
    double y = operateOffset.dx;
    double module = sqrt(x * x + y * y);
    return Vector3(x / module, y / module, 0.0);
  }

  Matrix4 calTransformByTagInfo(PlanetTagInfo tagInfo, double currentAngle) {
    var result = Matrix4.identity();
    result.translate(
        tagInfo.planetTagPos.x * reloadAnimationController.value,
        tagInfo.planetTagPos.y * reloadAnimationController.value,
        tagInfo.planetTagPos.z * reloadAnimationController.value);
    result.scale(tagInfo.scale);
    return result;
  }
}

class PlanetTagInfo {
  Vector3 planetTagPos = Vector3(0, 0, 0);
  Widget child;
  double currentAngle = 0;
  double radius = 0;

  PlanetTagInfo({required this.planetTagPos, required this.child});

  double get opacity {
    var result = 0.9 * ((radius + planetTagPos.z) / (radius * 2)) + 0.1;
    return result.isNaN || result.isNegative ? 0.0 : result;
  }

  double get scale {
    var result = ((radius + planetTagPos.z) / (radius * 2)) * 6 / 8 + 2 / 8;
    return result.isNaN || result.isNegative ? 0.0 : result;
  }
}

使用

children内为任意Widget 就是星球中个一个点

Dart 复制代码
PlanetWidget(
            children: [
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head3.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head2.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head1.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head3.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head2.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head1.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head3.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head2.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head1.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head3.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head2.image(),
                ),
              ),
              Container(
                width: 80,
                height: 80,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(40),
                  child: Assets.images.head1.image(),
                ),
              ),
            ],
          ),
相关推荐
sunshine6413 分钟前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻26 分钟前
Vue(四)
前端·javascript·vue.js
蜜獾云28 分钟前
npm淘宝镜像
前端·npm·node.js
dz88i828 分钟前
修改npm镜像源
前端·npm·node.js
Jiaberrr32 分钟前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook
顾平安2 小时前
Promise/A+ 规范 - 中文版本
前端
聚名网2 小时前
域名和服务器是什么?域名和服务器是什么关系?
服务器·前端
桃园码工2 小时前
4-Gin HTML 模板渲染 --[Gin 框架入门精讲与实战案例]
前端·html·gin·模板渲染
不是鱼2 小时前
构建React基础及理解与Vue的区别
前端·vue.js·react.js
沈剑心2 小时前
如何在鸿蒙系统上实现「沉浸式」页面?
前端·harmonyos