Drawing

2021-11-26  本文已影响0人  walkerwzy

《Programming iOS 14: Dive Deep into Views, View Controllers, and Frameworks》第2章


Drawing

Many UIView subclasses, such as a UIButton or a UILabel, know how to draw themselves.

A pure UIView is all about drawing, and it leaves that drawing largely up to you.

Images and Image Views

图片可以来自文件,代码,或网络。

Image Files

尽量把图片放到asset catalog里,对不同的处理器,更宽的色域,等等
不光影响运行时,在Apple Store对你的app对特定设备进行thinning都会用到
不同size class, dark mode, ipad等等trait collection都可以设置对应的图片

Vector images

Asset catalogs and trait collections

指定trait collection初始化图片:init(named:in:compatibleWith:)

let tcreg = UITraitCollection(verticalSizeClass: .regular)
let tccom = UITraitCollection(verticalSizeClass: .compact)
let moods = UIImageAsset()
let frowney = UIImage(named:"frowney")!
let smiley = UIImage(named:"smiley")!
moods.register(frowney, with: tcreg)
moods.register(smiley, with: tccom)

由此也可见,你操作的是“一张图片”,其实它是一带了条件的图片。

UIColor也是相同的机制,你用resolvedColor(with:)传入trait collection把对应的颜色取出来使用。

Namespacing image files

Image Views

A UIImageView can actually have two images, one assigned to its image property and the other assigned to its highlightedImage property
A UIImageView without an image and without a background color is invisible

Resizable Images

用inset来设置拉伸的区域,比如一般我们碰到的多为左右随便拉伸的胶囊按钮,需要设计师做的就是左右两个半圆(不拉伸)和中间1像素的可拉伸部分

let marsTiled = mars.resizableImage(withCapInsets:
UIEdgeInsets(
    top: mars.size.height / 2.0 - 1,
    left: mars.size.width / 2.0 - 1,
    bottom: mars.size.height / 2.0 - 1,
    right: mars.size.width / 2.0 - 1
), resizingMode: .stretch)

所以如果只是横向拉伸,上面的代码中,top, bottom都可以设为0,或都设为图片高度(而不去除2什么的),只需要保证把UI控件的高度保持跟图片一致即可。

那么,如果不小心高度大于图片高度了呢?分两种情况,如果设了0,表示没有保留区域,直接竖向拉伸,而如果设成了图片高度,那么表示整个Y方向没有可供拉伸的像素,必然造成拉伸失败:

image.png

Transparency Masks

The image shown on the screen is formed by combining the image’s transparency values with a single tint color.

忽略图片各像素上颜色的数值,只保留透明度,就成了一个mask. (renderingMode: alwaysTemplate)

Reversible Images

Graphics Contexts

Graphics Contexts是绘图的起点,你能从如下方式得到Graphics Contexts:

  1. 进入UIView的 draw(_:)方法时,系统会给你提供一个Graphics Contexts
  2. CALayer的draw(in:),或其代理的draw(_:in:)方法,in参数就是Graphics Contexts
    • 但它不是currnet context
  3. 手动创建一个

UIKit 和 Core Graphics是两套绘制工具。

两套体系,三种context来源,共计6种殊途同归的方式。

Drawing on Demand

直接上代码:

// UIView

// UIKit
override func draw(_ rect: CGRect) {
    // 直接绘制
    let p = UIBezierPath(ovalIn: CGRect(0,0,100,100))
    UIColor.blue.setFill()
    p.fill()
}

// CG
override func draw(_ rect: CGRect) {
    // 取到context
    let con = UIGraphicsGetCurrentContext()!
    con.addEllipse(in:CGRect(0,0,100,100))
    con.setFillColor(UIColor.blue.cgColor)
    con.fillPath()
}

// CALayer

// UIKit
 override func draw(_ layer: CALayer, in con: CGContext) {
    UIGraphicsPushContext(con)
    let p = UIBezierPath(ovalIn: CGRect(0,0,100,100))
    UIColor.blue.setFill()
p.fill()
    UIGraphicsPopContext()
}

// CG
override func draw(_ layer: CALayer, in con: CGContext) {
    con.addEllipse(in:CGRect(0,0,100,100))
    con.setFillColor(UIColor.blue.cgColor)
    con.fillPath()
}

Drawing a UIImage

let r = UIGraphicsImageRenderer(size:CGSize(100,100))
let im = r.image { _ in
    let p = UIBezierPath(ovalIn: CGRect(0,0,100,100))
    UIColor.blue.setFill()
    p.fill()
}
// im is the blue circle image, do something with it here ...
And here’s the same thing using Core Graphics:
let r = UIGraphicsImageRenderer(size:CGSize(100,100))
let im = r.image { _ in
    let con = UIGraphicsGetCurrentContext()!
    con.addEllipse(in:CGRect(0,0,100,100))
    con.setFillColor(UIColor.blue.cgColor)
    con.fillPath()
}
// im is the blue circle image, do something with it here ...

UIImage Drawing

用已有的图像进行绘制:

let mars = UIImage(named:"Mars")!
let sz = mars.size
let r = UIGraphicsImageRenderer(size:CGSize(sz.width*2, sz.height),
    format:mars.imageRendererFormat)
let im = r.image { _ in
    mars.draw(at:CGPoint(0,0))
    mars.draw(at:CGPoint(sz.width,0))
}

这里,绘制了两个火星,注意imageRendererFormat的使用

CGImage Drawing

let mars = UIImage(named:"Mars")!
// extract each half as CGImage
let marsCG = mars.cgImage!
let sz = mars.size
let marsLeft = marsCG.cropping(to:
    CGRect(0,0,sz.width/2.0,sz.height))!
let marsRight = marsCG.cropping(to:
    CGRect(sz.width/2.0,0,sz.width/2.0,sz.height))!
let r = UIGraphicsImageRenderer(size: CGSize(sz.width*1.5, sz.height),
    format:mars.imageRendererFormat)
let im = r.image { ctx in
    let con = ctx.cgContext
    con.draw(marsLeft, in:
        CGRect(0,0,sz.width/2.0,sz.height))
    con.draw(marsRight, in:
        CGRect(sz.width,0,sz.width/2.0,sz.height))
}

当然, con.draw可以由UIImage来完成:

UIImage(cgImage: marsLeft!,
scale: mars.scale,
orientation: mars.imageOrientation).draw(at:CGPoint(0,0))

Snapshots

Core Image

The “CI” in CIFilter and CIImage stands for Core Image, a technology for transforming images through mathematical filters. (iOS 5起,从macOS引入)

用途:

There are more than 200 available CIFilters, A CIFilter is a set of instructions for generating a CIImage

UIImage只有在已经wraps了一个CIImage的情况下.ciImage才有值,而大多数情况下是没有的。

Core Image Filter Reference里有所有的filter的名字,用来初始化一个filter

let filter = CIFilter(name: "CICheckerboardGenerator")!
// or:
let filter = CIFilter.checkerboardGenerator()

// 用key-value来决定行为:
filter.setValue(30, forKey: "inputWidth")
// or:
filter.width = 30
// or init with params
init(name:parameters:)

// apply filter on CIImage(if exists one)
ciimage.applyingFilter(_:parameters:)
// or output a ciimage
filter.outputImage

Render a CIImage
CIImage 不是一个displayaable image

以上说的都是"render" CIImage的时机,所以传入的

Metal能快速渲染CIImage

串起一个demo:

let moi = UIImage(named:"Moi")!
let moici = CIImage(image:moi)!
let moiextent = moici.extent
let smaller = min(moiextent.width, moiextent.height)
let larger = max(moiextent.width, moiextent.height)
// first filter
let grad = CIFilter.radialGradient()
grad.center = moiextent.center
grad.radius0 = Float(smaller)/2.0 * 0.7
grad.radius1 = Float(larger)/2.0
let gradimage = grad.outputImage!
// 到此步为止,并没有moi这个图片参与,等于是一个纯filter

// second filter
let blend = CIFilter.blendWithMask()
blend.inputImage = moici  // 设置了image
blend.maskImage = gradimage // 这里演示的是mask filter,按我理解并不是链式的,而且语法上也不是链式的,而是赋值给了maskImage,但书里直接说是链式的
let blendimage = blend.outputImage!

// 两种render方法
// content
let moicg = self.context.createCGImage(blendimage, from: moiextent)! // *
self.iv.image = UIImage(cgImage: moicg)

// UIImage
let r = UIGraphicsImageRenderer(size:moiextent.size)
self.iv.image = r.image { _ in
    UIImage(ciImage: blendimage).draw(in:moiextent) // *
}

关于上述代码里我的疑惑,第一个filter并不是chain到第二个filter里的,但书里说是obtain the final CIImage in the chain (blendimage),看来所谓的chain,并不是fitler的chain,而是outputImage`的chain?
问题是,这是唯一且标准的filter嵌套用法么?-> mask

不是的

  1. 对filter的outputImage继续应用aplyingFilter(_:parameters)来链式应用一个新的filter
    • 返回值是CIImage,不再是filter
    • 所以如果继续chain,直接用返回值调apply...方法即可
  2. 把上一个filter的outputImage设为下一个filter的inputImage:
CIFilter *gloom = [CIFilter filterWithName:@"CIGloom"];
[gloom setDefaults];                                        
[gloom setValue: result forKey: kCIInputImageKey];
[gloom setValue: @25.0f forKey: kCIInputRadiusKey];         
[gloom setValue: @0.75f forKey: kCIInputIntensityKey];      
// 即outputImage
CIImage *result = [gloom valueForKey: kCIOutputImageKey];   

CIFilter *bumpDistortion = [CIFilter filterWithName:@"CIBumpDistortion"];
[bumpDistortion setDefaults];                                              
// 设置inputImage (with first filter's output image) 
[bumpDistortion setValue: result forKey: kCIInputImageKey];
[bumpDistortion setValue: [CIVector vectorWithX:200 Y:150]
                forKey: kCIInputCenterKey];                              
[bumpDistortion setValue: @100.0f forKey: kCIInputRadiusKey];                
[bumpDistortion setValue: @3.0f forKey: kCIInputScaleKey];                   
result = [bumpDistortion valueForKey: kCIOutputImageKey];

CIImage能认出EXIF里关于旋转方向的参数,并以正确的方向展示

Blur and Vibrancy Views

毛玻璃效果,用UIVisualEffectView,这是个抽像类,实际用这两个:UIVisualEffectViewUIVibrancyEffect

什么是UIVibrancyEffect?

An object that amplifies and adjusts the color of the content layered behind a visual effect view.

关键词是behind,即它是配合别的视效一起用的(比如毛玻璃)。文字被毛玻璃覆盖后的效果,并不是由毛玻璃层来确定的,而是由vibrancy effect自定义的。

总的来说

let blurEffect = UIBlurEffect(style: .systemThinMaterial)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.frame = self.view.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.view.addSubview(blurView)
let vibEffect = UIVibrancyEffect(
    blurEffect: blurEffect, style: .label)
let vibView = UIVisualEffectView(effect:vibEffect)
let lab = UILabel()
lab.text = "Hello, world"
lab.sizeToFit()
vibView.bounds = lab.bounds
vibView.center = self.view.bounds.center
vibView.autoresizingMask =
    [.flexibleTopMargin, .flexibleBottomMargin,
    .flexibleLeftMargin, .flexibleRightMargin]
blurView.contentView.addSubview(vibView)
vibView.contentView.addSubview(lab)

Drawing a UIView

UIView本身就提供了一个graphics context,在这个context里进行的绘制会直接显示在view里。

推荐在draw方法里实时绘制

In fact, moving code to draw(_: ) is commonly a way to increase efficiency. This is because it is more efficient for the drawing engine to render directly onto the screen than for it to render offscreen and then copy those pixels onto the screen.

几个注意点:

  1. 不要手动调用draw方法,setNeedsDisplay会让系统决定下一个合适的时机来draw
  2. 不要重载draw方法,比如你无法合并UIImageView的drawing
  3. 不要在draw里做任何与绘制无关的事,配置(如背景色,添加子view/layer)项应该在别的地方做,比如layoutSubviews
  4. 第二个参数是一个rect,默认是view的bounds
    • 如果你用setNeesDisplay(_:)送入了自定义的CGRect,draw里面的rect也就成了这个,如果你不在这个rect里画(而是在整个view的rect里),超出部分会被clip掉
    • 这也是为了效率,显示提供绘制的区域
  5. 手写draw绘制出来的view会有黑色的底色,如果你没有设计背景色,以及isOpaque == true时(UIView.init(frame:)出来的view恰好满足这两个条件, nib里拖出来的则是nil的背景,反而没这问题)
    • 解决:实现init(frame:),去设置*isOpaque`为false

Graphics Context Commands

Under the hood, Core Graphics commands to a graphics context are global C functions with names like CGContextSetFillColor,但是swift的封装让调用更简单(语法糖)

当你在graphics context里绘制时,取的就是当前的设置,因此在任何绘制前,第一步都是先配置context's setting,比如你要画一根红线,再画一根蓝线

  1. 设置context line color red, then draw a line
  2. 设置context line color blue, then draw a line
    直觉认为红和蓝只是两条线各自的属性,其实是你绘制当时,整个graphics context的设置

Paths and Shapes

Clipping

这一节做了几个实验,单独写到了另一篇博文

前面说过,没有背景色+isOpaque会导致背景变黑,在draw里面,默认的颜色也是黑色,所以你不带任何设置的绘制你是看不到任何东西的(就是黑笔在黑纸上画)

Gradients

gradient不能用作path的fill,但可以反过来让gradient沿着path分布,以及被clip等。

在上面应用clip绘制箭尾的例子里,我们把箭柄变成从左到右是灰-黑-灰的渐变,只需要在addLine并设置了line的宽度后(不要设颜色了),不是去strokePath(),而是:

con.replacePathWithStrokedPath()  // 不再strokePath
con.clip()                        // 再clip一次,奇偶反转
// draw the gradient
let locs : [CGFloat] = [ 0.0, 0.5, 1.0 ]
let colors : [CGFloat] = [
        0.8, 0.4, // starting color, transparent light gray
        0.1, 0.5, // intermediate color, darker less transparent gray
        0.8, 0.4, // ending color, transparent light gray
    ]
let sp = CGColorSpaceCreateDeviceGray()
let grad = CGGradient(
    colorSpace:sp, colorComponents: colors, locations: locs, count: 3)!
con.drawLinearGradient(grad,
    start: CGPoint(89,0), end: CGPoint(111,0), options:[])
con.resetClip() // done clipping

小技巧就是用replacePathWithStrokedPath假装进行了描边(所以只需要线宽并不需要线的颜色),返回了一个新的path,一条粗线变成了一个矩形框。
而一旦添加了这个框,前面的奇偶关系就全反过来了,于是我们再clip一次,这就是头两行代码里做的事。

Colors and Patterns

当你的suer interface sytle changes(比如黑暗模式切换), draw(_:)方法会被立刻调用,被设置UITraitCollection.current,任何支持动态颜色的UIColor能变成相应的颜色,但是CGColor不能,你需要手动触发重绘。

UIKit使用pattern非常简单,把纹理绘制到图片上,然后从纹理图片提取出颜色信息,就能像别的颜色一样setFill了:

// create the pattern image tile
let r = UIGraphicsImageRenderer(size:CGSize(4,4))
let stripes = r.image { ctx in
    let imcon = ctx.cgContext
    imcon.setFillColor(UIColor.red.cgColor)
    imcon.fill(CGRect(0,0,4,4))
    imcon.setFillColor(UIColor.blue.cgColor)
    imcon.fill(CGRect(0,0,4,2))
}
// paint the point of the arrow with it
let stripesPattern = UIColor(patternImage:stripes)
stripesPattern.setFill()
let p = UIBezierPath()
p.move(to:CGPoint(80,25))
p.addLine(to:CGPoint(100,0))
p.addLine(to:CGPoint(120,25))
p.fill()

而Core Graphics则要复杂(也更底层)得多,结合注释看代码:

con.saveGState()
// 非常重要,设置颜色空间
let sp2 = CGColorSpace(patternBaseSpace:nil)!
con.setFillColorSpace(sp2)
// 纹理绘制真正发生的地方
let drawStripes : CGPatternDrawPatternCallback = { _, con in
    con.setFillColor(UIColor.red.cgColor)
    con.fill(CGRect(0,0,4,4))
    con.setFillColor(UIColor.blue.cgColor)
    con.fill(CGRect(0,0,4,2))
}
// 包装成一个callback给CGPattern使用
var callbacks = CGPatternCallbacks(
    version: 0, drawPattern: drawStripes, releaseInfo: nil) // 一个struct

// 核心就是构造这个CGPattern
let patt = CGPattern(info:nil, bounds: CGRect(0,0,4,4),  // cell大小
    matrix: .identity,    // cell变换,这里没有,就用.identity
    xStep: 4, yStep: 4,   // 横向纵向复制cell时的步长
    tiling: .constantSpacingMinimalDistortion,  // 排列方式
    isColored: true,      // 是颜色还是画笔模式,选颜色true
    callbacks: &callbacks)!  // 纹理绘制的方法包在callback里面,传指针
var alph : CGFloat = 1.0
con.setFillPattern(patt, colorComponents: &alph)
con.move(to:CGPoint(80, 25))
con.addLine(to:CGPoint(100, 0))
con.addLine(to:CGPoint(120, 25))
con.fillPath()
con.restoreGState()

Graphics Context Transforms

跟前面的知识点一样,应用Graphics Context Transforms后,也不会影响当前已经绘制的东西。 => CTM即(current transform matrix)。

旋转的中心点是原点,大多数情况下不是你想要的,记得先translate一下。

override func draw(_ rect: CGRect) {
    let con = UIGraphicsGetCurrentContext()!
    con.setShadow(offset: CGSize(7, 7), blur: 12) // 顺便演示下sahdow
    con.beginTransparencyLayer(auxiliaryInfo: nil)  // 这样重叠的阴影不会叠成黑色
    self.arrow.draw(at:CGPoint(0,0))
    for _ in 0..<3 {
        con.translateBy(x: 20, y: 100)
        con.rotate(by: 30 * .pi/180.0)
        con.translateBy(x: -20, y: -100)
        self.arrow.draw(at:CGPoint(0,0)) // 注意这里是用前面方法生成的箭头图片来draw到指定位置
    } 
}
image.png

注意,语法虽然是先处理context,再绘制,其实只是告知坐标系的变化,绘制的时候自动应用这些变换。

Erasing

clear(_:)擦除行为取决于context是透明还是实心的(透明擦成透明,实心擦成黑色),只要不是opaque,通通理解为透明,比如background color是nil, 或0.9999的透明度。

Points and Pixels

con.fill(CGRect(100,0,1.0/self.contentScaleFactor,100))应用contentScaleFactor画一条在任何屏幕上都锐利的1像素直线。

Content Mode

the drawing system will avoid asking a view to redraw itself from scratch if possible; instead, it will use the cached result of the previous drawing operation (the bitmap backing store).

If the view is resized, the system may simply stretch or shrink or reposition the cached drawing, if your contentMode setting instructs it to do so.

draw(_:)从原点开始绘制,所以你的contentMode也要相应设置为topLeft。而如果设置为.redraw,则不会使用cached content,每当view被resize的时候,就会调用setNeedsDisplay方法,最终触发draw(_:)进行重绘。

上一篇 下一篇

猜你喜欢

热点阅读