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(),
                ),
              ),
            ],
          ),
相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马10 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒11 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端