1

什么是3d文字球

我也不知道专业的名字叫什么,总之效果如下:
动态效果

需要考虑的关键问题

  1. 如何创建坐标,如何根据坐标方便的控制球绕着x和y轴旋转

  2. 如何实现3d效果

  3. 如何使得各个标题在球上均匀分布

  4. 如何实现点击标题后的回调函数

我的解决方案

关于坐标

肯定是使用球坐标最方便啦,这里需要再温习一下球坐标的表示方法:
球坐标系

如图,一个3维空间中的点,用球坐标可以表示为(r, Ry, Rxz),其中r是半径大小,Ry是半径和y轴的夹角;Rxz是半径在xz平面上的投影和z轴的夹角;类似的,这个点也可以表示为(r, Rx, Ryz),Rx是半径和x轴的夹角;Ryz是半径在yz平面上的投影和z轴的夹角。这样,绕着x轴旋转就改变Ryz,绕着y轴的旋转就改变Rxz即可。事实上,这2种坐标是可以相互转换的,因为他们都表示球面上同一个点,使用2种表示,只是为了方便后续控制文字球绕着x、y轴旋转。

3d效果

确定了坐标系的选择,3d效果只要保证把坐标算对就行了。但是,在iOS中单纯设置zPosition无法实现透视效果(即近处文字大远处文字小),需要设置

self.layer.transform.m34 = CGFloat(-1.0/perspective)

才行。perspective越小,深度越大,透视效果越强。具体解释见这里

球面title的均匀分布问题

我采用的算法是这样的:

先根据纬度,在球上等间距切出N个圆环,然后计算N个圆环的总长度,用title总数除以该长度,得到每单位长度获得的title数,然后单位title数*每个圆环的周长即为每个圆环上应该获得的title数,用360度除以这个数,就得到圆上每隔多少度需要安排一个title了。

实现点击标题后的回调函数

UIView类本身就包含touchesEnded:withEvent:函数,所以子类override这个函数即可。该函数可以获得touch到的x、y坐标,算出该坐标下的所有title,取zPosition最大的作为目标title即可。swift中,回调函数可以当做变量传入,跟javascript类似。当确定了目标title之后,把该title作为回调函数的参数传入,执行回调函数。

具体代码实现

代码结构

详细代码见github,我在文章里就说一些重点和当时遇到的坑。欢迎大家批评指正!

主要代码在Label3DView.swift文件中,其中包含2个类:

// Label3DView.swift
// 容器View类,包含所有的title。负责对title进行统一配置:
public class Label3DView: UIView {
// 配置项包括:
    public var perspective:Float = 2000
    public var fontSize:CGFloat = 15
    public var fontColor:UIColor = UIColor.blackColor()
    public var sphereRadius:Float = 0.5
    public var onEachLabelClicked : ((label:UILabel)->Void)?
// 功能函数包括:
//  从resource文件中读取所有title:
    public func loadLabelsFromFile(fpath:String) {}
//  配置项重新赋值后,统一配置所有title,把它们正确画在容器View中,需要在viewLoad时调用:
    public func resetLabelOnView() {}
}

// 这是放置每个title的label类,继承自UILabel。最核心的代码都在这里面
class LabelSphere: UILabel {
    // cx、cy保存x、y轴的偏移。因为我的球坐标系默认以(0,0,0)为原点,但是画到容器类时,必须偏移到容器类的中心点。
    var cx:Float
    var cy:Float 
    // 下面的属性名字基本上和以上介绍球坐标系时一样。
    // 半径在xz平面上的投影和z轴之间的夹角
    var rxz:Float
    // label和z轴夹角
    var ry:Float
    var radius:Float
    // 辅助属性:ryz和rx都是根据rxz、ry和radius算出来的。
    // 半径在yz平面上的投影和z轴之间的夹角
    var ryz:Float 
    // 辅助属性:
    // label和x轴夹角
    var rx:Float 
    
    var perspective:Float
}

辅助属性的计算

这里需要特别繁琐的数学运算,尽管原理其实很简单。但是对于毕业已经好几年的我来说,还是有些挑战。。。

先说明一下,半径和坐标轴之间的夹角(如ry和rx)取值范围是0到π,而投影和轴之间的夹角(如ryz和rxz)取值范围则是0到2π。只有这样才能保证取到空间中的所有点。

之所以强调这个,是因为这在通过反余弦计算角度时非常重要!
反余弦曲线是这样的:
反余弦曲线

其中正常x的取值只能在-1和1之间,而这样算出来的角度只能在0到π之间。而ryz的取值是在0到2π之间,这该怎么办呢?
其实很简单,以下图为例:
反余弦计算说明

其中a、b分别是2个直线和x轴的夹角,但是一个大于π,一个小于π。通过反余弦可以正确算出b = arccos(x),但是a就需要通过 (2π - arccos(x))算出,可以通过判断y值是否小于0来决定是否需要这样处理。

这样,我的代码就容易理解了:

// 首先根据ry、rxz和radius算出x、y、z坐标,这点很容易,都是非常基本的三角运算:
func getXYZ() -> (Float, Float, Float) {
    let pxz = sin(ry) * radius
    let px = sin(rxz) * pxz
    let pz = cos(rxz) * pxz
    
    return (px, cos(ry)*radius, pz)
}
// rx的计算也很简单,一个反余弦搞定!
var rx:Float {
    get {
        let (px, _, _) = getXYZ()
        return acos(px/radius)
    }
}
// 通过我刚才介绍的方法正确计算、设置ryz和rxz
var ryz:Float {
    get {
        let (_, py, pz) = getXYZ()
        let pyz = sqrt(py*py + pz*pz)
        let ryz = acos(pz/pyz)
        let PI = Float(M_PI)
        if py < 0 {
            return 2*PI - ryz
        } else {
            return ryz
        }
    }
    set {
        let pyz = sin(rx) * radius
        let py = sin(newValue) * pyz
        let pz = cos(newValue) * pyz
        let px = cos(rx) * radius
        let pxz = sqrt(px*px + pz*pz)
        
        ry = acos(py/radius)
        let PI = Float(M_PI)

        let new_rxz = acos(pz/pxz)
        if px < 0 {
            rxz = 2*PI - new_rxz
        }else {
            rxz = new_rxz
        }
    }
}

3d效果

开始我只是设置了zPosition和m34,发现没有3d效果,需要同时设置一下transform,同时为了使得3d效果更加明显,我还把距离较远的title进行了半透明处理。

// class LabelSphere: 
let old_pz = self.layer.zPosition
self.layer.zPosition = CGFloat(cos(rxz) * pxz)
setTransform3D(self.layer.zPosition - old_pz)
self.alpha = self.getAlpha()

func setTransform3D(dz:CGFloat) {
    let t = self.layer.transform
    self.layer.transform = CATransform3DTranslate(t, 0, 0, dz)
}
func getAlpha() -> CGFloat {
    var alpha = 2*self.layer.zPosition/CGFloat(perspective) + 0.5
    alpha = min(1.0, alpha)
    alpha = max(0.1, alpha)
    return alpha
}

点击事件

最简单的就是使用delegate了。但是这种方法总感觉比较麻烦,如果能像javascript那样直接赋值给一个闭包多好。

所以我就把回调函数定义为了一个函数:

public var onEachLabelClicked : ((label:UILabel)->Void)?

最开始的时候,我是这样给它赋值的

// 入口ViewController:
class ViewController: UIViewController {

    var label3dView: Label3DView?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 略过初始化操作 ......
        label3dView?.onEachLabelClicked = self.clickEachLabel
     }

    func clickEachLabel(label:UILabel) {
        let ac = UIAlertController(title: label.text!, message: self.labelDescription[label.text!],
                preferredStyle: .Alert)
        ac.addAction(UIAlertAction(title: "确定", style: .Cancel, handler: {
                [unowned self] _ in
                self.dismissViewControllerAnimated(true, completion: nil)
                }))
        self.presentViewController(ac, animated: true, completion: nil)
    }

但是这样在label3dView中就会引用ViewController实例,同时ViewController的实例也会引用label3dView,形成循环引用,导致二者的内存都无法释放!

为此我的解决方案是:

// 重新定义func clickEachLabel:

func clickEachLabel() -> ((l:UILabel)->Void){
    return {[unowned self] (label:UILabel) in
        let ac = UIAlertController(title: label.text!, message: self.labelDescription[label.text!],
            preferredStyle: .Alert)
        ac.addAction(UIAlertAction(title: "确定", style: .Cancel, handler: {
            [unowned self] _ in
            self.dismissViewControllerAnimated(true, completion: nil)
            }))
        self.presentViewController(ac, animated: true, completion: nil)
    }
}

然后在viewDidLoad中,这样给onEachLabelClicked赋值:

label3dView?.onEachLabelClicked = self.clickEachLabel()

这个思路来自于苹果官网论坛,感谢swiftgg的翻译(见Question2)

回调函数的事情,说完了,下一步就是在哪里调用它了。开始我把touchesEnded:withEvent:函数定义在LabelSphere里面,因为我觉得这样可能简单点。随之发现的问题是:

iOS的zPosition不会使得前面的元素覆盖下面的,即zPosition大的label,尽管视觉上它位于前面,但是它并没有覆盖后面的label,点击时后面的label可能会优先响应。因为我的label是通过父view的addSubView方法加入的,ios中后add进去的label会覆盖之前add的label,和zPosition的值无关!

所以,我只好在Label3DView这个容器类中定义touchesEnded:withEvent:函数,然后根据点击位置算出最靠前的label,把它作为目标label,传入回调函数。这样才是我预期的功能。

基本就这些了,代码都在github上,欢迎大家批评指正,共同学习!


flybywind
1.1k 声望38 粉丝