4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ZOZOテクノロジーズ #3Advent Calendar 2020

Day 9

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

Last updated at Posted at 2020-12-08

はじめに

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
スクリーンショット 2020-12-09 0.08.07.png スクリーンショット 2020-12-09 0.18.27.png

現状の実装

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を作成することができました。
スクリーンショット 2020-12-09 1.48.50.png

しかし、viewをclipした際の実装や、subViewにボーダーを描画する処理など課題はあるので今後実装していこうと思いますし、PRお待ちしています!

参考

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?