Я пришел к следующему подходу, собрав воедино предложения из других публикаций 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 рисования на угол, но по мере увеличения желаемой толщины обводки число раз, когда мы должны рисовать изображение обводки вдоль заданного угла, также должно увеличиваться, чтобы сохранить красивый ход.
Когда все штрихи были нарисованы, мы заканчиваем sh, заново рисуя исходное изображение в центре этого нового изображения, так что оно появляется перед всеми нарисованными штриховыми изображениями.
функция ниже заботится об этих оставшихся шагах:
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)
Вот пример штриха в действии, применяется к метке с белым текстом, с черным штрихом: