什么是3d文字球
我也不知道专业的名字叫什么,总之效果如下:
需要考虑的关键问题
如何创建坐标,如何根据坐标方便的控制球绕着x和y轴旋转
如何实现3d效果
如何使得各个标题在球上均匀分布
如何实现点击标题后的回调函数
我的解决方案
关于坐标
肯定是使用球坐标最方便啦,这里需要再温习一下球坐标的表示方法:
如图,一个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上,欢迎大家批评指正,共同学习!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。