CALayerのrender(in:)がやっているであろうことをUIViewのdraw(_:)で再現してみる

# はじめに
iOSDC 2020の[文字列をコピーできるスクリーンショットを作る](https://speakerdeck.com/endoumari/iosdc-2020-lt-pdf)
の発表で割愛した実装について紹介します。

コードは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|
|:---|:--|
|![スクリーンショット 2020-12-09 0.08.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308861/a68fa194-2dc8-8330-7476-31f0338f8585.png)|![スクリーンショット 2020-12-09 0.18.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308861/5d2a4ba5-403f-99d0-d69d-592f9f4729ea.png)|

**現状の実装**

```swift
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対応




# 実装

処理を追加後の実装は以下のとおりです。

```swift
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()
    }
}

```

```swift
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を戻す必要があります。

```swift
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の領域を塗りつぶします。

```swift

    if let backgroundColor = view.backgroundColor {
        backgroundColor.setFill()
        UIRectFill(view.bounds)
    }
```

## 透過処理
CGContextの`setAlpha(_:)`を使用して透過します。

```swift
context.setAlpha(view.alpha)
```

画像の場合、上記処理では透過できないため、`draw(in:blendMode:alpha:)`を使用します。

```swift
imageView.image?.draw(in: imageView.contentModeBounds, blendMode: .normal, alpha: imageView.alpha)
```

## ボーダー描画

ボーダーは`UIBezierPath`を使用して描画しています。

```swift
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計算)](https://qiita.com/KikurageChan/items/74146ca89a99402df851)の記事をを参考にさせていただきました。

```swift
imageView.image?.draw(in: imageView.contentModeBounds, blendMode: .normal, alpha: imageView.alpha)
```

# おわりに
上記実装でレイアウトが崩れていない文字列をコピーできるPDFを作成することができました。
![スクリーンショット 2020-12-09 1.48.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308861/fea9f090-181c-f6f5-53c7-b448224e2e9b.png)

しかし、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