好吧,圆和椭圆是很好的,但圆角矩形如何?我们现在也可以那样做吧?——Steve Jobs
我们在第3章“图层几何”讲解了图层帧,在第2章“主图像”讲解了图层的主图像。但图层不仅是包含颜色和图像的矩形容器;它同样有一堆用于程序化创建令人印象深刻的优雅界面元素的内置特性。在这一章中,我们将讲解许多使用CALayer
属性所能实现的视觉特效。
圆角
使用圆角矩形(有圆角的矩形)是iOS美学中的一大显著特征。它们遍布iOS角角落落,从主屏的图标、模态的提醒到文本输入域。介于它们的流行你们可能猜测到,有不需要Photoshop帮助就能创建它们的简便方法。是的,你猜对了。
CALayer
有一个cornerRadius
属性用于控制图层四角的弯曲。它是一个默认为0(直角)的浮点数,但可以被设置为任何你喜欢的值(用点设置)。默认情况下,这个弯曲只能影响图层的背影颜色,但不能影响主图像或子图层。然而,当masksToBounds
属性被设置为YES
(看第2章),图层中的一切都会被裁剪。
我们可以用一个简单的项目来演示这种效果。让我们在Interface Builder中排列两个视图,它们拥有超出自身边界的子视图(如图4.1)。你并不能真的看见内部视图超出容器视图的情形,这是因为Interface Builder总是在编辑界面裁剪视图,但你只需要相信它们是这样的。
通过代码,我们将给第视图加上20点半径的圆角并允许第二个视图剪裁(如表4.1)。从技术层面上说,这些属性都可以直接在Interface Builder中分别使用用户定义运行时属性以及检查器面板中的裁剪子视图的复选框来实现,但在这个例子中为了清晰起见,我们使用代码完成。图4.2展示了结果。
表4.1 使用cornerRadius和maskToBounds
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView1: UIView!
@IBOutlet weak var layerView2: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 设置图层的圆角
self.layerView1.layer.cornerRadius = 20.0
self.layerView2.layer.cornerRadius = 20.0
// 允许第二个图层裁剪
self.layerView2.layer.masksToBounds = true
}
}
正如你所见,右边的红色视图被裁剪到父视图的圆角。
并不能单独操作图层每个角的弯曲度,所以如果你想要创建一个有一些圆角一些直角的图层或视图,只能另辟蹊径,比如使用一个图层遮罩(如本章稍后讲解的一样)或里使用CAShapeLayer
(见第6章“特定图层”)。
图层边框
CALayer
另一对有用的属性是borderWidth
以及borderColor
,它们共同定义了画在图层边缘的直线。这条线(被称作描边)围绕着图层的bounds
,包括角的弯曲。
borderWidth
是用点定义描边粗细的浮点数。它默认为0(无边框)。borderColor
定义搭边的颜色,默认为黑色。
borderColor
的类型是CGColorRef
,而非UIColor
,因此本质上并非是Cocoa
对象。然而,你应该记住即使无法没有属性声明,图层也一直持有borderColor
。CGColorRef
更像是NSObject
而不是持有/释放,但Objective-C语法并没有提供指示,因即使强持有CGColorRef
属性也得用assign
声明。
边框是画在图层边界内的,并且在包括子图层的所有图层内容之前。如何我们修改例子来引入图层边框(如表4.2),你可以看到它们的效果(如图4.3)。
表4.2 加边框
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView1: UIView!
@IBOutlet weak var layerView2: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 设置图层的圆角
self.layerView1.layer.cornerRadius = 20.0
self.layerView2.layer.cornerRadius = 20.0
// 增加图层的边框
self.layerView1.layer.borderWidth = 5.0
self.layerView2.layer.borderWidth = 5.0
// 允许第二个图层裁剪
self.layerView2.layer.masksToBounds = true
}
}
注意,图层边框并不会计算图层主图像或子图层的形状。如果子图层超出它的bounds
,或者主图像有一个透明度遮罩包含透明区域,边框仍然会绕着图层的(可能是圆角)的矩形(如图4.4)。
阴影
另一个iOS的普遍特征是阴影。阴影被投射在视图后来显示深度。他们被用来指明图层关系和优先级(例如当一个模态提示出现在另一个视图之前),但它们有时只是用来起装饰作用(用来控制一个更完善的界面)。
通过设置shadowOpacity
属性一个大于0(默认值),可以在任意图层后增加阴影。shadowOpacity
是一个浮点数,应该被设置在0.0(不可见)和1.0(完全不透明)之间。设置为1.0会显示一个略高于图层的带有轻微模糊的黑色阴影。为了微调阴影的效果,你可以使用CALayer
的三个额外属性:shadowColor
、shadowOffset
以及shadowRadius
。
shadowColor
属性如同名字所示,是一个控制阴影颜色的CGColorRef
,就像borderColor
和backgroundColor
属性一样。默认的阴影为黑色,这通常是大多情况下你所需要的(彩色的阴影在现实中很少见,而且看起来有点奇怪)。
shadowOffset
属性用于控制阴影延伸的方向的距离。它是一个CGSize
值,宽用于控制阴影的水平方向偏移,高用于控制竖直方向上的偏移。默认的shadowOffset
是{0, 3}
,这会在使阴影位移图层Y轴向上3点处。
为什么默认阴影指向上?尽管Core Animation
是改自Layer Kit(为iOS创建的私有动画框架),它第一次现身是在Mac OS上,而Mac OS用了和iOS相反的坐标系统(Mac OS的Y轴指向上)。在Mac上,同样默认的shadowOffset
值会产生一个向-下-指的阴影,所以默认方向在那种情况下更有意义(如图4.5)。
Apple的惯例是用户界面的阴影竖直向下,所以在iOS上大多情况下使用零宽度和正高度的阴影可能是最好的。
shadowRaidus
属性控制阴影的模糊程序。如果设为0会创建一个正好符合视图形状的硬边阴影。一个大一点的数值可以创建一个更为自然的软边阴影。Apple自家的应用设计偏好使用软阴影,因此最好给shadowRadius
使用一个非零数值。
通常,如果你应该给一个如模态提醒这样从背景中凸显出来的视图一个更大的shadowRadius
;阴影越模糊,显得深度越深(如图4.6)。
阴影裁剪
不同于图层边框,图层阴影会完整继承内容的形状,而不仅是bounds
和cornerRadius
。为了计算阴影的形状,Core Animation
用主图像(如果有子图层也会使用)来创建一个完美匹配图层形状的阴影(如图4.7)。
图层阴影在组合剪裁上有一个扰人的限制:因为阴影通常会画在图层边界外,如果你允许masksToBounds
属性,阴影会和其它伸出图层的内容一样被裁剪。如果我们给我们的边框案例项目添加图层阴影,你会看到这种问题(如图4.8)。
从技术层面来看这种情况是可以理解的,但它可能并不像是你想要的效果。如果你想裁剪内容同时投射阴影,你需要使用两个图层:一个空的外图层用于绘制阴影,一个允许masksToBounds
的内图层用来裁剪内容。
如果你在我们的项目中对右边视图的裁剪视图上添加一个环绕的视图就可以解决这个问题(如图4.9)。
我们仅在最外面的视图上添加阴影,仅在内视图中允许裁剪。表4.3展示了修改后的代码,图4.10展示了结果。
表4.3 使用一个额外视图来解决阴影裁剪问题
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView1: UIView!
@IBOutlet weak var layerView2: UIView!
@IBOutlet weak var shadowView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 设置图层的圆角
self.layerView1.layer.cornerRadius = 20.0
self.layerView2.layer.cornerRadius = 20.0
// 增加图层的边框
self.layerView1.layer.borderWidth = 5.0
self.layerView2.layer.borderWidth = 5.0
// 给layerView1添加阴影
self.layerView1.layer.shadowOpacity = 0.5
self.layerView1.layer.shadowOffset = CGSizeMake(0.5, 5.0)
self.layerView1.layer.shadowRadius = 5.0
// 给shadowView添加同样的阴影(不是layerView2)
self.shadowView.layer.shadowOpacity = 0.5
self.shadowView.layer.shadowOffset = CGSizeMake(0.5, 5.0)
self.shadowView.layer.shadowRadius = 5.0
// 允许第二个图层裁剪
self.layerView2.layer.masksToBounds = true
}
}
shadowPath属性
我们已经创建的阴影并不总是方形,而是从内容的形状继承。这看起来很棒,但在实际情况它的计算代价十分昂贵,尤其当图层包含多个子图层并且每个都有一个透明度遮罩的主图像时。
如果你事先知道阴影可能的形状,你可以通过指定shadowPath
来显著提升性能。shadowPath
是一个CGPathRef
(一个指向CGPath
的对象)。CGPath
是一个用来指定直接向量形状的Core Graphics
对象。我们可以用它定义独立于图层形状的阴影形状。
图4.11展示了两个用于同样图层图像的不同的阴影形状。在这里,我们用的形状很简单,但它们完全可以是任何你想要的形状。看表4.4的代码[1]。
表4.4 创建简单的阴影路径
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView1: UIView!
@IBOutlet weak var layerView2: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 允许图层阴影
self.layerView1.layer.shadowOpacity = 0.5
self.layerView2.layer.shadowOpacity = 0.5
// 创建方形阴影
let squarePath = CGPathCreateMutable()
CGPathAddRect(squarePath, nil, self.layerView1.bounds)
self.layerView1.layer.shadowPath = squarePath
// 创建圆形阴影
let circlePath = CGPathCreateMutable()
CGPathAddEllipseInRect(circlePath, nil, self.layerView2.bounds)
self.layerView2.layer.shadowPath = circlePath
}
}
对于创建一些类似矩形和圆的形状来说,手动创建一个CGPath
是十分简单的。对于更复杂的如圆角矩形的形状,你会发现使用UIKit
提供的CGPath
的Objective-C的封装类UIBezierPath
会更加轻松。
图层遮罩
我们知道使用masksToBounds
属性可以把图层的内容裁剪到它的bounds
,而使用cornerRadius
属性我们甚至可以给它圆角。但有时你可以想不用矩形甚至不是圆角矩形来展示内容。例如,你可能想给一个图像创建星形相框,或者你想滚动的文字边缘漂亮地渐渐消失在背景中而不是直接被边缘裁剪。
使用一个有透明组件的32位PNG图像,你可以指定一个有直接透明遮罩的主图像,这通常是创建非矩形视图最简单的方法。但这个方法不允许你程序化地动态裁剪图像来生成遮罩或让子图层、子视图裁剪成同样的形状。
CALayer
有一个叫做mask
的属性可以帮助解决这个问题。mask
属性本身是一个CALayer
并且有其它图层所应有的全部绘图和布局属性。它与相对父图层放置的子图层的使用方法很像,但它并不是显示成一个正常的子图层。并非画进你图层中,mask
图层决定父图层的哪些部分是可见的。
mask
图层的颜色是不重要的;有用的是它的轮廓。mask
如同一个曲奇模具,mask
图层的实体部分将会从父图层中切出并保存;其他将被抛弃(如图4.12)。
如果mask
图层小于父图层,只有父图层(或其子图层)与mask
相交的部分可见。如果你使用了mask
图层,图层之外的一切将隐藏。
为了展示这一点,让我们创建一个简单的项目,我们用图层的mask
属性来将一张图像作为另一张图像的遮罩。为了方便起见,我们在Interface Builder中使用UIImageView
来创建我们的图像图层,仅仅程序化地创建并应用mask
图层。表4.5展示了这些代码,图4.13展示了结果[2]。
表4.5 使用图层遮罩
import UIKit
class ViewController: UIViewController {
@IBOutlet var imageView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 创建遮罩图层
let maskLayer = CALayer()
// 原书中设置
// maskLayer.frame = self.imageView.bounds
// 译者设置
maskLayer.frame = CGRectMake(200, 100, 200, 200)
let maskImage = UIImage(named: "Cone.png")!
maskLayer.contents = maskImage.CGImage
// 将遮罩应用于图像图层
self.imageView.layer.mask = maskLayer
}
}
CALayer
遮罩的一个非常酷炫的特性是你并非只能用静态图像作为遮罩。任何可以组成图层的东西都可以被作为mask
属性,这意味你的遮罩可以用代码动态生成,甚至产生动画。
缩放过滤器
本章最后一个话题是minificationFilter
和magnificationFilter
属性的作用。通常在iOS上,当你显示图像时,你应该尝试以正确的尺寸显示它们(就是图像像素与屏幕像素以1:1显示)。其原因如下:
- 它能提供最好的质量,因为像素既不会拉伸也不会被重新采样。
- 它能最好地利用RAM,因为你不会存储无用的像素点。
- 它有最好的性能,因为GPU不需要过多工作。
然而有时的确需要或大或小地显示一个图像。比如人物或化身的缩略图,一个可供用户拖动、缩放的大图。这时如果为每个可能需要展示的图像分别存储不同尺寸的版本就十分不方便。
当以不同尺寸显示图像时,一个被称作缩放过滤的算法被应用于源始图片来产生新的要显示在屏幕上的图像。
无论你是放大还是缩小,都没有通用的理想的绽放图像的算法。其方法的优劣决定于被被缩放图像的属性。CALayer
提供了三种缩放图像的绽放过滤器供选择。它们用如下的字符串常量表示:
- kCAFilterLinear
- kCAFilterNearest
- kCAFIlterTrilinear
kCAFilterLinear
是minification(缩小图像)和magnification(放大图像)的默认过滤器。这个过滤器使用双线性过滤算法,这在大多情况下会产生好的效果。双线性过滤工作原理是对多个像素点采样来创建最终值。结果不错,而且绽放得很平滑,但如果放大过多倍数会使图像看起来模糊(如图4.14)。
kCAFilterTrlinear
选项与kCAFilterLinear
十分类似。大多数情况下二者并无视觉上的区别,但三线性过滤法比双线性过滤拥有更好的性能。它通过存储图像的多种尺寸(被称作MIP贴图)然后在三维坐标中重新采样,然后结合大一点和小一点的图像来产生最终的结果。
这个方法的优势在于可以很好地工作于一组早已经接近最终值的图像。这意味着它不需要同时重新采样尽量多的像素值,这提升了性能而且可以避免由于极小缩放因子导致的精度问题而引发的采样失效。
kCAFilterNearest
选项是最粗暴的方法。正如名字所示,这个算法(被称作近邻取样过滤)直接采取最近的一个点,而且根本没有颜色混合。这十分快速而且不会模糊图像,但对于缩小图像的质量明显较差,放大图像会变成块状和像素化。
在图4.14中,注意看在缩小图像时近邻取样会比双线性过滤更加扭曲,而放大时看起来更加模糊。对比图4.15,其开始是一张非常小的图像。这种情况下,近邻取样会更好地保留源始像素,而无论放大缩小线性过滤都将它们变成模糊的一团。
通常来说,对于有鲜明对比而较少对角线的极小图或极大图(例如,计算机生成的图像),近邻取样缩放可以保留对比产生可能更好的结果。但对于大多数图像,尤其是照片或者有对角线或弯曲的图像,近邻取样会比线性过滤效果差。用另一种方法来说,线性过滤保留形状,近邻取样过滤保留像素。
让我们试一个真实的例子。我们将修改第3章的时钟项目来显示一个LCD风格的数字时钟来代替模拟时钟。这些数字会用十分简单的像素字体创建(这个字体中字符是用独立像素而非向量图形组成),它们存储在一起并会用第2章讲解的精灵图集技术显示(如图4.16)。
我们将在Interface Builder中排列6个视图,两个表示小时,两个表示分钟,两个表示秒。图4.17展示它们是如何排列的。用单独的出口绑定这些视图显得十分笨拙,所以我们用IBOutletCollection
来将它们连向控制器,这允许我们以数组的形式访问视图。表4.6展示了这个时钟的代码。
表4.6 显示LCD风格时钟
import UIKit
class ViewController: UIViewController {
@IBOutlet var digitViews: [UIView]!
var timer: NSTimer!
override func viewDidLoad() {
super.viewDidLoad()
// 获得精灵图集
let digits = UIImage(named: "Digits.png")!
// 设置数字视图
for view in self.digitViews {
// 设置内容
view.layer.contents = digits.CGImage
view.layer.contentsRect = CGRectMake(0, 0, 0.1, 1.0)
view.layer.contentsGravity = kCAGravityResizeAspect
}
// 启动计时器
self.timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "tick", userInfo: nil, repeats: true)
// 设置开始时间
self.tick()
}
func setDigit(digit: NSInteger, forView view: UIView) {
// 调整contentRect到选择的数字
view.layer.contentsRect = CGRectMake(CGFloat(digit) * 0.1, 0, 0.1, 1.0)
}
func tick() {
// 将时间转换成小时、分钟和秒
let calendar = NSCalendar(calendarIdentifier: NSCalendarIdentifierChinese)!
let units = NSCalendarUnit.CalendarUnitHour | NSCalendarUnit.CalendarUnitMinute | NSCalendarUnit.CalendarUnitSecond
let components = calendar.components(units, fromDate: NSDate())
// 设置小时
self.setDigit(components.hour / 10, forView: self.digitViews[0])
self.setDigit(components.hour % 10, forView: self.digitViews[1])
// 设置分钟
self.setDigit(components.minute / 10, forView: self.digitViews[2])
self.setDigit(components.minute % 10, forView: self.digitViews[3])
// 设置秒
self.setDigit(components.second / 10, forView: self.digitViews[4])
self.setDigit(components.second % 10, forView: self.digitViews[5])
}
}
译者实现的效果和原著有所不同,因为图像过于清晰,因此会在之后贴出原著效果,在这显示一下译者实现的效果
正如图4.18所示,它起效了但数字看起来很模糊,看起来应该是默认的kCAFilterLinear
选项导致的。
为了得到图4.19所示的清晰时钟,我们只需要在我们程序的for...in
循环中加入下面这行:
view.layer.magnificationFilter = kCAFilterNearest
组透明
UIView
有一个好用的alpha
属性可可用于改变透明度。CALayer
有一个相同属性叫opacity
。这些属性都层次式生效,所以如果你设置了某一图层的opacity
,它会同样自动在所有子图层生效。
iOS中的一个普遍技巧是设置控件的alpha
为0.5(50%)来使其不可见。这对于单独视图效果极好,但如果视图有子视图会显得有点奇怪。图4。20展示了一个自定义的有UILabel
的UIButton
;其左侧为一个不透明的按钮,右侧是一个被设为50%透明度的同样按钮。注意观察我们可以在按钮背景上看到内在标签的轮廓。
这个效果是由于透明度混合导致的。当你以50%透明度显示图层时,图层的每一像素都显示50%的自身颜色以及底层图层的50%。这导致了半透明的效果。但如果这个图层的子图层也显示为50%半透明,当你看向子图层时它会显示子图层的50%颜色,25%容器的颜色,只有25%的背景色。
在我们的例子里,按钮和标签都有白色背景。即使它们都只有50%透明度,它们结合的透明度就是75%,因此按钮上的标签看起来比它周围透明度小一点。这对所有的子图层都生效,会让一个控件产生不好的视觉效果。
理想情况下,当你设置图层的opacity
,你希望它的整个子树像是一个整体一样渐隐,而不用考虑其内部结构。你可以通过在你的Info.plist
文件中将UIViewGroupOpacity
设置为YES
来达到这一目的,但这会影响整个应用的混合,导致一个小的应用级问题。如果UIViewGroupOpacity
键删除了,在iOS6及之前的系统中它默认为NO
(这一默认情况在未来的iOS版中可能改变)。
另一种方法是,你可以指定CALayer
的一个叫shouldResterize
的属性来实现某一图层子树的组透明(如表4.7)。当设为YES
时,shouldRasterize
属性会导致在透明度设置应用前图层和它的子图层折叠成一个单独的平面图像,因此解决了混合失效的问题(如图4.21)。
除了允许shouldRasterize
属性,我们也修改了图层的rasterizationScale
属性。默认情况下,所有的图层栅格化的缩放比为1.0,因此如果你使用shouldRasterize
属性,你应该总是确保自己设置了rasterizationScale
来匹配屏幕以防视图在一个Retina显示设备上看起来像素化。
如同UIViewGroupOpacity
一样,使用shouldRasterize
属性会有一些隐式的表现(这将在第12章“微调速度”和第15章“图层表现”中解释),但这个表现影响被本土化了。
表4.7 使用shouldRasterize来修复组混合问题
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
var customButton: UIButton {
get {
// 创建按钮
var frame = CGRectMake(0, 0, 150, 50)
let button = UIButton(frame: frame)
button.backgroundColor = UIColor.whiteColor()
button.layer.cornerRadius = 10
// 添加标签
frame = CGRectMake(20, 10, 110, 30)
let label = UILabel(frame: frame)
label.text = "Hello World"
label.backgroundColor = UIColor.whiteColor()
label.textAlignment = NSTextAlignment.Center
button.addSubview(label)
return button
}
}
override func viewDidLoad() {
super.viewDidLoad()
// 创建不透明按钮
let button1 = self.customButton
button1.center = CGPointMake(50, 150)
self.containerView.addSubview(button1)
// 创建半透明按钮
let button2 = self.customButton
button2.center = CGPointMake(250, 150)
button2.alpha = 0.5
self.containerView.addSubview(button2)
// 允许半透明按钮的栅格化
button2.layer.shouldRasterize = true
button2.layer.rasterizationScale = UIScreen.mainScreen().scale
}
}
然而译者未使用栅格化的效果也是如此,猜测Apple在iOS8中改变了
总结
这一章介绍了你可以程序化应用到图层上的一些视觉特效,例如圆角、阴影和遮罩。我们也介绍了缩放过滤器以及组透明。
在第5章“变形”中我们将研究图层变形和将我们的图层转入第三维度。