はじめに
iOSDC 2020の文字列をコピーできるスクリーンショットを作る
の発表で割愛した実装について紹介します。
コードはGitHubで公開しています。
https://github.com/EndouMari/SampleScreenshot
モチベーション
文字列をコピーできるPDFを作成したい場合、CALayerのrender(in:)
を使用すると、ビットマップを描画するため文字列をコピーできません。
UIViewのdraw(_:)
を使用して描画すると文字列をコピー可能なPDFが作成できるのですが、レイアウトなどの処理が行われずレイアウトが崩れてしまいます。CALayerのrender(in:)はどうやら描画だけではなく、レイアウト処理なども行っていることがわかりました。そこで、CALayerの
render(in:)が行っているであろう処理を追加してUIViewのdraw(_:)
を使用してもレイアウトが崩れないPDFを作成します。
現状
UIViewのdraw(_:)
を使用して描画した場合はレイアウトの崩れだけではなく背景の描画などもしていません。
この状態からオリジナルのレイアウトになるようにしていきます。
オリジナル | UIViewのdraw(_:)で描画したPDF |
---|---|
現状の実装
let data = renderer.pdfData { context in
context.beginPage()
func recursiveDraw(view: UIView) {
if let imageView = view as? UIImageView {
imageView.image?.draw(in: imageView.bounds)
} else {
view.draw(view.bounds)
}
view.subviews.forEach {
recursiveDraw(view: $0)
}
}
pdfView.subviews.forEach {
recursiveDraw(view: $0)
}
}
UIGraphicsEndPDFContext()
現状はviewを再帰的ににUIViewのdraw(_:)で描画しているだけです。
ここから以下の処理を追加していきます。
- レイアウト処理
- 背景描画
- 透過処理
- ボーダー描画
- 画像のAspectFill対応
実装
処理を追加後の実装は以下のとおりです。
func createPDF(_ screenshotService: UIScreenshotService,
generatePDFRepresentationWithCompletion completionHandler: @escaping (Data?, Int, CGRect) -> Void) {
let renderer = UIGraphicsPDFRenderer(bounds: pdfView.bounds)
let data = renderer.pdfData { context in
context.beginPage()
func recursiveDraw(view: UIView) {
draw(view: view, context: context.cgContext, rootView: pdfView)
view.subviews.forEach {
recursiveDraw(view: $0)
}
}
pdfView.subviews.forEach {
recursiveDraw(view: $0)
}
drawBorder(view: pdfView, context: context.cgContext)
}
UIGraphicsEndPDFContext()
completionHandler(data, 0, view.bounds)
}
private func draw(view: UIView, context: CGContext, rootView: UIView) {
let bounds = rootView.convert(view.bounds, from: view)
context.saveGState()
context.translateBy(x: bounds.minX, y: bounds.minY)
context.setAlpha(view.alpha)
if let backgroundColor = view.backgroundColor {
backgroundColor.setFill()
UIRectFill(view.bounds)
}
if let imageView = view as? UIImageView {
imageView.image?.draw(in: imageView.contentModeBounds, blendMode: .normal, alpha: imageView.alpha)
} else {
view.draw(view.bounds)
}
context.restoreGState()
}
private func drawBorder(view: UIView, context: CGContext) {
if let borderColor = view.layer.borderColor {
let borderWidth = view.layer.borderWidth
let path: UIBezierPath
let rect: CGRect = .init(x: view.bounds.minX + (borderWidth / 2), y: view.bounds.minY + (borderWidth / 2), width: view.bounds.width - borderWidth, height: view.bounds.height - borderWidth)
if view.layer.cornerRadius > 0 {
path = UIBezierPath(roundedRect: rect, cornerRadius: view.layer.cornerRadius)
} else {
path = UIBezierPath(rect: rect)
}
UIColor(cgColor: borderColor).setStroke()
path.lineWidth = borderWidth
path.stroke()
}
}
extension UIImageView {
private var aspectFitSize: CGSize {
get {
guard let aspectRatio = image?.size else { return .zero }
let widthRatio = bounds.width / aspectRatio.width
let heightRatio = bounds.height / aspectRatio.height
let ratio = (widthRatio > heightRatio) ? heightRatio : widthRatio
let resizedWidth = aspectRatio.width * ratio
let resizedHeight = aspectRatio.height * ratio
let aspectFitSize = CGSize(width: resizedWidth, height: resizedHeight)
return aspectFitSize
}
}
var aspectFitBounds: CGRect {
get {
let size = aspectFitSize
return CGRect(origin: CGPoint(x: bounds.size.width * 0.5 - size.width * 0.5, y: bounds.size.height * 0.5 - size.height * 0.5), size: size)
}
}
private var aspectFillSize: CGSize {
get {
guard let aspectRatio = image?.size else { return .zero }
let widthRatio = bounds.width / aspectRatio.width
let heightRatio = bounds.height / aspectRatio.height
let ratio = (widthRatio < heightRatio) ? heightRatio : widthRatio
let resizedWidth = aspectRatio.width * ratio
let resizedHeight = aspectRatio.height * ratio
let aspectFitSize = CGSize(width: resizedWidth, height: resizedHeight)
return aspectFitSize
}
}
var aspectFillBounds: CGRect {
get {
let size = aspectFillSize
return CGRect(origin: CGPoint(x: bounds.origin.x - (size.width - bounds.size.width) * 0.5, y: bounds.origin.y - (size.height - bounds.size.height) * 0.5), size: size)
}
}
var contentModeBounds: CGRect {
guard let image = image else { return .zero }
switch contentMode {
case .scaleToFill, .redraw:
return bounds
case .scaleAspectFit:
return aspectFitBounds
case .scaleAspectFill:
return aspectFillBounds
case .center:
let x = bounds.size.width * 0.5 - image.size.width * 0.5
let y = bounds.size.height * 0.5 - image.size.height * 0.5
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .topLeft:
return CGRect(origin: CGPoint.zero, size: image.size)
case .top:
let x = bounds.size.width * 0.5 - image.size.width * 0.5
let y: CGFloat = 0
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .topRight:
let x = bounds.size.width - image.size.width
let y: CGFloat = 0
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .right:
let x = bounds.size.width - image.size.width
let y = bounds.size.height * 0.5 - image.size.height * 0.5
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .bottomRight:
let x = bounds.size.width - image.size.width
let y = bounds.size.height - image.size.height
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .bottom:
let x = bounds.size.width * 0.5 - image.size.width * 0.5
let y = bounds.size.height - image.size.height
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .bottomLeft:
let x: CGFloat = 0
let y = bounds.size.height - image.size.height
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
case .left:
let x: CGFloat = 0
let y = bounds.size.height * 0.5 - image.size.height * 0.5
return CGRect(origin: CGPoint(x: x, y: y), size: image.size)
@unknown default:
return bounds
}
}
}
レイアウト処理
左上に寄って描画されているviewをCGContextのtranslateBy(x:y:)
で正しい位置に描画するようにします。
このとき、saveGState()
で現在のstateを保存、描画後にrestoreGState()
保存しているstateを戻す必要があります。
private func draw(view: UIView, context: CGContext, rootView: UIView) {
let bounds = rootView.convert(view.bounds, from: view)
context.saveGState()
context.translateBy(x: bounds.minX, y: bounds.minY)
context.setAlpha(view.alpha)
if let backgroundColor = view.backgroundColor {
backgroundColor.setFill()
UIRectFill(view.bounds)
}
if let imageView = view as? UIImageView {
imageView.image?.draw(in: imageView.contentModeBounds, blendMode: .normal, alpha: imageView.alpha)
} else {
view.draw(view.bounds)
}
context.restoreGState()
}
背景描画
背景はUIRectFill(_:)
を使用してviewの領域を塗りつぶします。
if let backgroundColor = view.backgroundColor {
backgroundColor.setFill()
UIRectFill(view.bounds)
}
透過処理
CGContextのsetAlpha(_:)
を使用して透過します。
context.setAlpha(view.alpha)
画像の場合、上記処理では透過できないため、draw(in:blendMode:alpha:)
を使用します。
imageView.image?.draw(in: imageView.contentModeBounds, blendMode: .normal, alpha: imageView.alpha)
ボーダー描画
ボーダーはUIBezierPath
を使用して描画しています。
private func drawBorder(view: UIView, context: CGContext) {
if let borderColor = view.layer.borderColor {
let borderWidth = view.layer.borderWidth
let path: UIBezierPath
let rect: CGRect = .init(x: view.bounds.minX + (borderWidth / 2), y: view.bounds.minY + (borderWidth / 2), width: view.bounds.width - borderWidth, height: view.bounds.height - borderWidth)
if view.layer.cornerRadius > 0 {
path = UIBezierPath(roundedRect: rect, cornerRadius: view.layer.cornerRadius)
} else {
path = UIBezierPath(rect: rect)
}
UIColor(cgColor: borderColor).setStroke()
path.lineWidth = borderWidth
path.stroke()
}
}
画像のAspectFill対応
contentModeに合わせたboundsを設定することで対応します。
contentModeの計算はUIImageViewの画像の表示領域を計算するExtension(UIImageView UIImage size計算)の記事をを参考にさせていただきました。
imageView.image?.draw(in: imageView.contentModeBounds, blendMode: .normal, alpha: imageView.alpha)
おわりに
上記実装でレイアウトが崩れていない文字列をコピーできるPDFを作成することができました。
しかし、viewをclipした際の実装や、subViewにボーダーを描画する処理など課題はあるので今後実装していこうと思いますし、PRお待ちしています!
参考
- https://developer.apple.com/library/archive/documentation/GraphicsImaging/Conceptual/drawingwithquartz2d/dq_overview/dq_overview.html
- https://developer.apple.com/library/archive/documentation/WindowsViews/Conceptual/ViewPG_iPhoneOS/Introduction/Introduction.html
- https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/SettingUpLayerObjects/SettingUpLayerObjects.html