轴标签 轴_Flutter伪3D旋转标签云

1f6e5fa26ad9a50e397155b3018e0b76.png

先看看效果预览:

26f2d72f4306424acc550e518a238b99.png
https://www.zhihu.com/video/1190392603169009664

本文用小球体来代替标签,将小球换成文本即为标签云

制造一个旋转的标签云,需要解决两个问题:

  1. 如何让标签旋转
  2. 如何制造旋转的感觉

如果使用threejs等3d引擎,这两个问题都有现成的接口调用,可以很简单轻松的实现。

但是使用真实3D模式比较重,往往要引入很大的sdk来实现。就这个场景而言,我们使用伪3D技术就足够了。

先来看看第一个问题,如何让标签旋转:

标签云主要的原理是使用罗德里格旋转矢量公式来计算一个点绕轴X旋转角度A后的新位置

代码如下:

 _rotatePoints(List<Point> points, Point axis, double angle) {
    //罗德里格旋转矢量公式
    //计算点 x,y,z 绕轴axis转动angle角度后的新坐标

    //预先缓存公司中一次旋转的不变值,如sin,cos等,避免重复计算
    var a = axis.x,
        b = axis.y,
        c = axis.z,
        a2 = a * a,
        b2 = b * b,
        c2 = c * c,
        ab = a * b,
        ac = a * c,
        bc = b * c,
        sinA = sin(angle),
        cosA = cos(angle);
    //批量旋转点
    points.forEach((point) {
      var x = point.x, y = point.y, z = point.z;
      point.x = (a2 + (1 - a2) * cosA) * x +
          (ab * (1 - cosA) - c * sinA) * y +
          (ac * (1 - cosA) + b * sinA) * z;
      point.y = (ab * (1 - cosA) + c * sinA) * x +
          (b2 + (1 - b2) * cosA) * y +
          (bc * (1 - cosA) - a * sinA) * z;
      point.z = (ac * (1 - cosA) - b * sinA) * x +
          (bc * (1 - cosA) + a * sinA) * y +
          (c2 + (1 - c2) * cosA) * z;
    });
    return points;
  }

具体算法可参见百度百科:

罗德里格旋转公式_百度百科​baike.baidu.com
cdcf56b19bef0ccb536645df17bdea1a.png

第二个问题,如何制造旋转的感觉:

将屏幕平面作为三维空间的X和Y轴,和屏幕垂直的轴假象为Z轴,根据Z坐标通过标签的大小和透明度来制造距离感:

double _getOpacity(double z) {
//  根据z坐标设置透明度, 制造距离感
  //在正面为1,背面最低0.1
  return z > 0 ? 1 : (1 + z) * 0.9 + 0.1;
}

double _getScale(double z) {
  //使用z坐标设置标签大小,制造距离感
  //从[-1,1]区间转移到[1/4,1]区间
  //背面最小时为正面1/16大小
  return z * 3 / 8 + 5 / 8;
}

除了两个基本问题之外,我们如何可以改变旋转的方向呢。通过滑动球面,我们可以改变球体转动方向。其原理是,当从点A滑动到点B时,和直线AB垂直的轴线即为新的转动轴:(注:转动轴向量Z轴的值永远为0,球体只能绕着XY平面的直线旋转,在这个伪3D场景中已经够用)

return GestureDetector(
      onPanUpdate: (dragUpdateDetails) {
        //滑动球体改变旋转轴
        //dx为滑动过的x轴距离,可有正负值
        //dy为滑动过的y轴距离,可有正负值
        var dx = dragUpdateDetails.delta.dx, 
            dy = dragUpdateDetails.delta.dy;
        //正则化,使轴向量长度为1
        var sqrtxy = sqrt(dx * dx + dy * dy);
        //避免除0
        if (sqrtxy > 4) 
          rotateAxis = Point(-dy / sqrtxy, dx / sqrtxy, 0);
      },

Flutter的动画使用起来也很简洁,只需要一个animation controller和一个插值对象Tween就可以驱动了:

animationController = new AnimationController(
      vsync: this,
      //按rpm,转/每分来计算旋转速度
      duration: Duration(seconds: 60 ~/ widget.rpm),
    );
    rotationAnimation =
        Tween(begin: 0.0, end: pi * 2).animate(animationController)
          ..addListener(() {
            setState(() {
              var angle = rotationAnimation.value;
              angleDelta = angle - prevAngle;//这段时间内旋转过的角度
              prevAngle = angle;
              //按angleDelta旋转标签到新的位置
              _rotatePoints(points, rotateAxis, angleDelta);
            });
          });
    animationController.repeat();

这个是伪3D实现,其中透明度和大小等都是可以调节,以制造更真实的感觉。

后面我将用threejs来实现一个真3D的标签云,以对比用真3D引擎来实现此类场景有多简单。

完全的代码:

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class Point {
  double x, y, z;
  Color color;

  Point(this.x, this.y, this.z, {this.color = Colors.white});
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '标签云',
      theme: ThemeData(
          primarySwatch: Colors.blue, scaffoldBackgroundColor: Colors.black),
      home: MyHomePage(title: '标签云'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  double rpm = 3;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child:
            Column(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
          Padding(
            padding: const EdgeInsets.all(20.0),
            child: LayoutBuilder(builder: (context, constraints) {
              return TagCloud(constraints.maxWidth, constraints.maxHeight,
                  rpm: this.rpm);
            }),
          ),
          Text(
            '滑动球体可以改变转动方向n滑动条可以改变转动速度',
            style: TextStyle(color: Colors.white, fontSize: 24),
            textAlign: TextAlign.center,
          ),
          Container(
            color: Colors.white,
            child: Slider(
                value: this.rpm,
                min: 0,
                max: 10,
                onChanged: (value) {
                  setState(() {
                    this.rpm = value;
                  });
                }),
          ),
        ]),
      ),
    );
  }
}

class TagCloud extends StatefulWidget {
  final double width, height, rpm; //rpm 每分钟圈数

  TagCloud(this.width, this.height, {this.rpm = 3});

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

class _TagCloudState extends State<TagCloud>
    with SingleTickerProviderStateMixin {
  Animation<double> rotationAnimation;
  AnimationController animationController;
  List<Point> points;
  int pointsCount = 20; //标签数量
  double radius, //球体半径
      angleDelta,
      prevAngle = 0.0;
  Point rotateAxis = Point(0, 1, 0); //初始为Y轴

  @override
  void initState() {
    super.initState();
    radius = widget.width / 2;
    points = _generateInitialPoints();
    animationController = new AnimationController(
      vsync: this,
      //按rpm,转/每分来计算旋转速度
      duration: Duration(seconds: 60 ~/ widget.rpm),
    );
    rotationAnimation =
        Tween(begin: 0.0, end: pi * 2).animate(animationController)
          ..addListener(() {
            setState(() {
              var angle = rotationAnimation.value;
              angleDelta = angle - prevAngle;//这段时间内旋转过的角度
              prevAngle = angle;
              //按angleDelta旋转标签到新的位置
              _rotatePoints(points, rotateAxis, angleDelta);
            });
          });
    animationController.repeat();
  }

  @override
  didUpdateWidget(oldWidget) {
    super.didUpdateWidget(oldWidget);
    setState(() {
      animationController.duration = Duration(seconds: 60 ~/ widget.rpm);
      if (animationController.isAnimating) animationController.repeat();
    });
  }

  _stopAnimation() {
    if (animationController.isAnimating)
      animationController.stop();
    else {
      animationController.repeat();
    }
  }

  _generateInitialPoints() {
    //生产初始点
    var floatingOffset = 15; //漂浮距离,越大漂浮感越强
    var radius = widget.width / 2 + floatingOffset;
    List<Point> points = [];
    for (var i = 0; i < pointsCount; i++) {
      double x =
          1 * Random().nextDouble() * (Random().nextBool() == true ? 1 : -1);
      double remains = sqrt(1 - x * x);

      double y = remains *
          Random().nextDouble() *
          (Random().nextBool() == true ? 1 : -1);

      double z =
          sqrt(1 - x * x - y * y) * (Random().nextBool() == true ? 1 : -1);

      points.add(new Point(x * radius, y * radius, z * radius,
          color: Color.fromRGBO(
            (x.abs() * 256).ceil(),
            (y.abs() * 256).ceil(),
            (z.abs() * 256).ceil(),
            1,
          )));
    }

    return points;
  }

  _rotatePoints(List<Point> points, Point axis, double angle) {
    //罗德里格旋转矢量公式
    //计算点 x,y,z 绕轴axis转动angle角度后的新坐标

    //预先缓存不变值,如sin,cos等,避免重复计算
    var a = axis.x,
        b = axis.y,
        c = axis.z,
        a2 = a * a,
        b2 = b * b,
        c2 = c * c,
        ab = a * b,
        ac = a * c,
        bc = b * c,
        sinA = sin(angle),
        cosA = cos(angle);
    points.forEach((point) {
      var x = point.x, y = point.y, z = point.z;
      point.x = (a2 + (1 - a2) * cosA) * x +
          (ab * (1 - cosA) - c * sinA) * y +
          (ac * (1 - cosA) + b * sinA) * z;
      point.y = (ab * (1 - cosA) + c * sinA) * x +
          (b2 + (1 - b2) * cosA) * y +
          (bc * (1 - cosA) - a * sinA) * z;
      point.z = (ac * (1 - cosA) - b * sinA) * x +
          (bc * (1 - cosA) + a * sinA) * y +
          (c2 + (1 - c2) * cosA) * z;
    });
    return points;
  }

  _buildPainter(points) {
    return CustomPaint(
      size: Size(radius * 2, radius * 2),
      painter: TagsPainter(points),
    );
  }

  _buildBody() {
    List<Widget> children = [];
    //球体,添加了边界阴影
    var sphere = Container(
        height: radius * 2,
        decoration: BoxDecoration(
            color: Colors.blueAccent,
            shape: BoxShape.circle,
            boxShadow: [
              BoxShadow(
                  color: Colors.white.withOpacity(0.9),
                  blurRadius: 30.0,
                  spreadRadius: 10.0),
            ]));
    children.add(sphere);
    children.add(_buildPainter(points));

    return GestureDetector(
      onPanUpdate: (dragUpdateDetails) {
        //滑动球体改变旋转轴
        //dx为滑动过的x轴距离,可有正负值
        //dy为滑动过的y轴距离,可有正负值
        var dx = dragUpdateDetails.delta.dx,
            dy = dragUpdateDetails.delta.dy;
        //正则化,使轴向量长度为1
        var sqrtxy = sqrt(dx * dx + dy * dy);
        //避免除0
        if (sqrtxy > 4)
          rotateAxis = Point(-dy / sqrtxy, dx / sqrtxy, 0);
      },
      child: Stack(
        children: children,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return _buildBody();
  }
}

class TagsPainter extends CustomPainter {
  List<Point> points;
  double radius = 16;
  double prevX = 0;
  var paintStyle = Paint()
    ..color = Colors.white
    ..style = PaintingStyle.fill;

  TagsPainter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.translate(size.width / 2, size.width / 2);
    points.forEach((point) {
      var opacity = _getOpacity(point.z / size.width * 2);
      paintStyle.color = point.color.withOpacity(opacity);
      var r = _getScale(point.z / size.width * 2) * radius;
      canvas.drawCircle(Offset(point.x, point.y), r, paintStyle);
    });
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

double _getOpacity(double z) {
//  根据z坐标设置透明度, 制造距离感
  //在正面为1,背面最低0.1
  return z > 0 ? 1 : (1 + z) * 0.9 + 0.1;
}

double _getScale(double z) {
  //使用z坐标设置标签大小,制造距离感
  //从[-1,1]区间转移到[1/4,1]区间
  //背面最小时为正面1/16大小
  return z * 3 / 8 + 5 / 8;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值