给定一个包含非不透明(alpha < 1)像素数据的UIImage,我们如何在不透明(alpha > 0)的像素周围绘制一个轮廓边框描边,并自定义描边颜色和厚度?(我提出这个问题是为了在下面提供答案)
我通过拼凑其他SO帖子中的建议,并将其改编成自己满意的东西,想出了以下方法。
第一步是获得一个UIImage开始处理。在某些情况下,你可能已经有了这个图像,但如果你可能想在一个UIView(也许是一个带有自定义字体的UILabel)中添加一个描边,你首先要捕获该视图的图像。
public extension UIView {
/// Renders the view to a UIImage
/// - Returns: A UIImage representing the view
func imageByRenderingView() -> UIImage {
layoutIfNeeded()
let rendererFormat = UIGraphicsImageRendererFormat.default()
rendererFormat.scale = layer.contentsScale
rendererFormat.opaque = false
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: rendererFormat)
let image = renderer.image { _ in
self.drawHierarchy(in: self.bounds, afterScreenUpdates: true)
}
return image
}
}
现在我们可以获得一个视图的图像,我们需要能够将它裁剪成不透明的像素。这一步是可选的,但对于像UILabels这样的东西来说,有时它们的边界比它们显示的像素要大。下面的函数需要一个完成块,这样它就可以在后台线程上执行繁重的工作(注意:UIKit不是线程安全的,但CGContexts是)。
public extension UIImage {
/// Converts the image's color space to the specified color space
/// - Parameter colorSpace: The color space to convert to
/// - Returns: A CGImage in the specified color space
func cgImageInColorSpace(_ colorSpace: CGColorSpace) -> CGImage? {
guard let cgImage = self.cgImage else {
return nil
}
guard cgImage.colorSpace != colorSpace else {
return cgImage
}
let rect = CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)
let ciImage = CIImage(cgImage: cgImage)
guard let convertedImage = ciImage.matchedFromWorkingSpace(to: CGColorSpaceCreateDeviceRGB()) else {
return nil
}
let ciContext = CIContext()
let convertedCGImage = ciContext.createCGImage(convertedImage, from: rect)
return convertedCGImage
}
/// Crops the image to the bounding box containing it's opaque pixels, trimming away fully transparent pixels
/// - Parameter minimumAlpha: The minimum alpha value to crop out of the image
/// - Parameter completion: A completion block to execute as the processing takes place on a background thread
func imageByCroppingToOpaquePixels(withMinimumAlpha minimumAlpha: CGFloat = 0, _ completion: @escaping ((_ image: UIImage)->())) {
guard let originalImage = cgImage else {
completion(self)
return
}
// Move to a background thread for the heavy lifting
DispatchQueue.global(qos: .background).async {
// Ensure we have the correct colorspace so we can safely iterate over the pixel data
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let cgImage = self.cgImageInColorSpace(colorSpace) else {
DispatchQueue.main.async {
completion(UIImage())
}
return
}
// Store some helper variables for iterating the pixel data
let width: Int = cgImage.width
let height: Int = cgImage.height
let bytesPerPixel: Int = cgImage.bitsPerPixel / 8
let bytesPerRow: Int = cgImage.bytesPerRow
let bitsPerComponent: Int = cgImage.bitsPerComponent
let bitmapInfo: UInt32 = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
// Attempt to access our pixel data
guard
let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo),
let ptr = context.data?.assumingMemoryBound(to: UInt8.self) else {
DispatchQueue.main.async {
completion(UIImage())
}
return
}
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
var minX: Int = width
var minY: Int = height
var maxX: Int = 0
var maxY: Int = 0
for x in 0 ..< width {
for y in 0 ..< height {
let pixelIndex = bytesPerRow * Int(y) + bytesPerPixel * Int(x)
let alphaAtPixel = CGFloat(ptr[pixelIndex + 3]) / 255.0
if alphaAtPixel > minimumAlpha {
if x < minX { minX = x }
if x > maxX { maxX = x }
if y < minY { minY = y }
if y > maxY { maxY = y }
}
}
}
let rectangleForOpaquePixels = CGRect(x: CGFloat(minX), y: CGFloat(minY), width: CGFloat( maxX - minX ), height: CGFloat( maxY - minY ))
guard let croppedImage = originalImage.cropping(to: rectangleForOpaquePixels) else {
DispatchQueue.main.async {
completion(UIImage())
}
return
}
DispatchQueue.main.async {
let result = UIImage(cgImage: croppedImage, scale: self.scale, orientation: self.imageOrientation)
completion(result)
}
}
}
}
最后,我们需要能够用我们选择的颜色填充一个UIImage。
public extension UIImage {
/// Returns a version of this image any non-transparent pixels filled with the specified color
/// - Parameter color: The color to fill
/// - Returns: A re-colored version of this image with the specified color
func imageByFillingWithColor(_ color: UIColor) -> UIImage {
return UIGraphicsImageRenderer(size: size).image { context in
color.setFill()
context.fill(context.format.bounds)
draw(in: context.format.bounds, blendMode: .destinationIn, alpha: 1.0)
}
}
}
现在我们可以开始着手解决这个问题了,在我们渲染的裁剪过的UIImage上添加一个描边,这个过程包括在输入的图像上填充所需的描边颜色,然后用描边的粗细来渲染图像与原始图像中心点的偏移,形成一个圆形。我们在0...360度范围内绘制这个 "描边 "图像的次数越多,产生的描边就越 "精确"。也就是说,默认的8个描边似乎对大多数事情已经足够了(结果是以0、45、90、135、180、225和270度的间隔呈现一个描边)。
此外,我们还需要为一个给定的角度多次绘制这个笔画图像。在大多数情况下,每个角度绘制1次就足够了,但随着所需的笔画厚度的增加,我们应该沿着给定的角度绘制笔画图像的次数也应该增加,以保持好看的笔画。
当所有的笔画都绘制完毕后,我们最后在这个新图像的中心重新绘制原始图像,使它出现在所有绘制的笔画图像的前面。
下面的函数就可以处理剩下的这些步骤。
public extension UIImage {
/// Applies a stroke around the image
/// - Parameters:
/// - strokeColor: The color of the desired stroke
/// - inputThickness: The thickness, in pixels, of the desired stroke
/// - rotationSteps: The number of rotations to make when applying the stroke. Higher rotationSteps will result in a more precise stroke. Defaults to 8.
/// - extrusionSteps: The number of extrusions to make along a given rotation. Higher extrusions will make a more precise stroke, but aren't usually needed unless using a very thick stroke. Defaults to 1.
func imageByApplyingStroke(strokeColor: UIColor = .white, strokeThickness inputThickness: CGFloat = 2, rotationSteps: Int = 8, extrusionSteps: Int = 1) -> UIImage {
let thickness: CGFloat = inputThickness > 0 ? inputThickness : 0
// Create a "stamp" version of ourselves that we can stamp around our edges
let strokeImage = imageByFillingWithColor(strokeColor)
let inputSize: CGSize = size
let outputSize: CGSize = CGSize(width: size.width + (thickness * 2), height: size.height + (thickness * 2))
let renderer = UIGraphicsImageRenderer(size: outputSize)
let stroked = renderer.image { ctx in
// Compute the center of our image
let center = CGPoint(x: outputSize.width / 2, y: outputSize.height / 2)
let centerRect = CGRect(x: center.x - (inputSize.width / 2), y: center.y - (inputSize.height / 2), width: inputSize.width, height: inputSize.height)
// Compute the increments for rotations / extrusions
let rotationIncrement: CGFloat = rotationSteps > 0 ? 360 / CGFloat(rotationSteps) : 360
let extrusionIncrement: CGFloat = extrusionSteps > 0 ? thickness / CGFloat(extrusionSteps) : thickness
for rotation in 0..<rotationSteps {
for extrusion in 1...extrusionSteps {
// Compute the angle and distance for this stamp
let angleInDegrees: CGFloat = CGFloat(rotation) * rotationIncrement
let angleInRadians: CGFloat = angleInDegrees * .pi / 180.0
let extrusionDistance: CGFloat = CGFloat(extrusion) * extrusionIncrement
// Compute the position for this stamp
let x = center.x + extrusionDistance * cos(angleInRadians)
let y = center.y + extrusionDistance * sin(angleInRadians)
let vector = CGPoint(x: x, y: y)
// Draw our stamp at this position
let drawRect = CGRect(x: vector.x - (inputSize.width / 2), y: vector.y - (inputSize.height / 2), width: inputSize.width, height: inputSize.height)
strokeImage.draw(in: drawRect, blendMode: .destinationOver, alpha: 1.0)
}
}
// Finally, re-draw ourselves centered within the context, so we appear in-front of all of the stamps we've drawn
self.draw(in: centerRect, blendMode: .normal, alpha: 1.0)
}
return stroked
}
}
把所有的步骤结合在一起,你就可以像这样在一个UIImage上应用一个描边。
let inputImage: UIImage = UIImage()
let outputImage = inputImage.imageByApplyingStroke(strokeColor: .black, strokeThickness: 2.0)
下面是一个描边操作的例子,应用于一个带有白色文字的标签,并有一个黑色的描边。