0

I have a UIScrollView and I draw a line and a string in it's contentView of type DrawView. I want to maintain the width of the drawn elements relative to the zoomScale. Below is my code.

class ViewController: UIViewController {
    
    @IBOutlet private weak var scrollView: UIScrollView!
    @IBOutlet private weak var drawView: DrawView!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        scrollView.maximumZoomScale = 20.0
        scrollView.minimumZoomScale = 0.1
        scrollView.zoomScale = 1.0
        
        scrollView.backgroundColor = .lightGray
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        drawView.setNeedsDisplay()
    }
}

extension ViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return drawView
    }
    
    func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
        drawView.zoomScale = scale
        drawView.setNeedsDisplay()
    }
}

and this is my DrawView

public class DrawView: UIView {
    
    public var zoomScale: CGFloat = 1.0
    
    public override func draw(_ rect: CGRect) {
        drawLine()
        drawString()
    }
    
    private func drawLine() {
        let path = UIBezierPath()

        path.move(to: CGPoint(x:100, y:300))
        path.addLine(to: CGPoint(x: 100, y: 400))
        path.close()

        UIColor.red.set()
        path.lineWidth = 2/zoomScale
        path.stroke()
    }
    
    private func drawString() {
        let font = UIFont.systemFont(ofSize: 30/zoomScale)
        let string = NSAttributedString(string: "Test", attributes: [NSAttributedString.Key.font: font,
                                                                     NSAttributedString.Key.foregroundColor: UIColor.red])
        string.draw(at: CGPoint(x: 200, y: 200))
    }
}

Below are the results

When zoomScale is 1.0

enter image description here

When zoomScale is 5.0

enter image description here

When zoomScale is 5.0

enter image description here

When I zoom, the intended width is maintained, but the elements are pixelated.

Expectation:

When zoomScale is 5.0

enter image description here enter image description here

It could be noticed that the current results are pixelated. What would be an ideal way to achieve the expected result which is scaled and sharp?

iOS
  • 3,526
  • 3
  • 37
  • 82
  • *"I want to maintain the width of the drawn elements relative to the zoomScale"* -- that's not quite clear... Try to describe it in a little more detail. And, you've shown an image of what you're doing that is **NOT** giving you your desired result - how about adding an image that shows what you **WANT** to get. – DonMag Mar 21 '23 at 13:25
  • @DonMag 1. I wanted to scale the added line and text, when the scrollView was zoomed. That's what I am doing in `path.lineWidth = 2/zoomScale` and `let font = UIFont.systemFont(ofSize: 30/zoomScale)`. Scaling them for the zoomScale. – iOS Mar 21 '23 at 14:18
  • @DonMag 2. When the scrollView was zoomed, both line and text must be scaled to look sharp as it is displayed when `zoomScale` was `1.0`. Currently they are pixelated as you see in images #2 and #3. – iOS Mar 21 '23 at 14:37
  • I deleted my first answer, as that was only useful for small UI elements. Take a look at my "new" answer. – DonMag Apr 03 '23 at 19:55

1 Answers1

1

One option is to use a fixed-size "drawView" and transform your paths and font-sizes.

Here's a basics example:

class BasicScalingView: UIView {
    
    public var zoomScale: CGFloat = 1.0 { didSet { setNeedsDisplay() } }
    
    private var theLinePath: UIBezierPath!
    private var theOvalPath: UIBezierPath!
    private var theTextPoint: CGPoint!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        var someRect: CGRect = .zero
        
        // create a rect path
        someRect = .init(x: 4.0, y: 4.0, width: 80.0, height: 50.0)
        theLinePath = UIBezierPath()
        theLinePath.move(to: .init(x: someRect.maxX, y: someRect.minY))
        theLinePath.addLine(to: .init(x: someRect.minX, y: someRect.minY))
        theLinePath.addLine(to: .init(x: someRect.minX, y: someRect.maxY))

        //  create an oval path
        someRect = .init(x: 6.0, y: 8.0, width: 50.0, height: 30.0)
        theOvalPath = UIBezierPath(ovalIn: someRect)

        //  this will be the top-left-point of the text-bounds
        theTextPoint = .init(x: 8.0, y: 6.0)
        
    }
    
    override func draw(_ rect: CGRect) {
        
        // only draw if we've initialized the paths
        guard theLinePath != nil, theOvalPath != nil else { return }
        
        let tr = CGAffineTransform(scaleX: zoomScale, y: zoomScale)
        
        if let path = theLinePath.copy() as? UIBezierPath {
            //  transform a copy of the rect path
            path.apply(tr)
            
            UIColor.green.set()
            path.lineWidth = 2.0 * zoomScale
            path.stroke()
        }
        
        if let path = theOvalPath.copy() as? UIBezierPath {
            //  transform the path
            path.apply(tr)
            
            UIColor.systemBlue.set()
            UIColor(white: 0.95, alpha: 1.0).setFill()
            path.lineWidth = 2.0 * zoomScale
            path.fill()
            path.stroke()
        }
        
        // scale the font point-size
        let font: UIFont = .systemFont(ofSize: 30.0 * zoomScale)
        let attribs: [NSAttributedString.Key : Any] = [.font: font, .foregroundColor: UIColor.red]
        //  transform the point
        let trPT: CGPoint = theTextPoint.applying(tr)
        //  attributed string at zoomed point-size
        let string = NSAttributedString(string: "Sample", attributes: attribs)
        string.draw(at: trPT)
        
    }
    
}

That BasicScalingView is what we'll use as the "drawView." When we set the zoomScale it will redraw itself, transforming the line path, the oval path, the top-left point for the text and the font size.

We can show that by using a slider to change the zoom scale:

enter image description here

enter image description here

enter image description here

As we see, the lines and curves remain sharp and in position relative to each other.

Now we could use Pinch and Pan gestures, and write a bunch of code to track the zoom scale value and the relative position to allow zooming and panning. We'd also need to use the gestures' .location, .velocity, etc properties to implement edge bouncing. With some searching, we could probably find some samples for that.

But... wouldn't it be nice if we could use all of those built-in functions with a scroll view?

Well, we can...

First, we'll use a fairly simple modified "scaling view" that has zoomScale and contentOffset properties, which we will update when we get scrollViewDidZoom and scrollViewDidScroll.

It draws a rectangle, a novel (inset a bit) and a text string, all centered in the view - looks like this to start:

enter image description here

What we do is put the "drawView" behind a clear scroll view, and we'll use a plain, clear UIView as the viewForZooming:

enter image description here

When we zoom / pan the scroll view, we get this:

enter image description here

enter image description here

The empty "clear" view that we use for viewForZooming can be very big, and can zoom-in to a high zoom scale without memory issues.

Using a "complex" scaling view as our "drawView" -- creating a 32-column x 40-row "grid" of rectangles (alternating rounded and square), ovals, text strings, and a few "SwiftyBird" bezier paths.

Looks like this (scrolled all the way to bottom-right):

enter image description here

and, after some zooming / panning:

enter image description here

enter image description here

Here's the complete code to run these examples... no @IBOutlet or @IBAction connections - just assign a fresh view controller to TheBasicsVC and then SimpleVC and then ComplexVC:

class BasicScalingView: UIView {
    
    public var zoomScale: CGFloat = 1.0 { didSet { setNeedsDisplay() } }
    
    private var theLinePath: UIBezierPath!
    private var theOvalPath: UIBezierPath!
    private var theTextPoint: CGPoint!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        var someRect: CGRect = .zero
        
        // create a rect path
        someRect = .init(x: 4.0, y: 4.0, width: 80.0, height: 50.0)
        theLinePath = UIBezierPath()
        theLinePath.move(to: .init(x: someRect.maxX, y: someRect.minY))
        theLinePath.addLine(to: .init(x: someRect.minX, y: someRect.minY))
        theLinePath.addLine(to: .init(x: someRect.minX, y: someRect.maxY))

        //  create an oval path
        someRect = .init(x: 6.0, y: 8.0, width: 50.0, height: 30.0)
        theOvalPath = UIBezierPath(ovalIn: someRect)

        //  this will be the top-left-point of the text-bounds
        theTextPoint = .init(x: 8.0, y: 6.0)
        
    }
    
    override func draw(_ rect: CGRect) {
        
        // only draw if we've initialized the paths
        guard theLinePath != nil, theOvalPath != nil else { return }
        
        let tr = CGAffineTransform(scaleX: zoomScale, y: zoomScale)
        
        if let path = theLinePath.copy() as? UIBezierPath {
            //  transform a copy of the rect path
            path.apply(tr)
            
            UIColor.green.set()
            path.lineWidth = 2.0 * zoomScale
            path.stroke()
        }
        
        if let path = theOvalPath.copy() as? UIBezierPath {
            //  transform the path
            path.apply(tr)
            
            UIColor.systemBlue.set()
            UIColor(white: 0.95, alpha: 1.0).setFill()
            path.lineWidth = 2.0 * zoomScale
            path.fill()
            path.stroke()
        }
        
        // scale the font point-size
        let font: UIFont = .systemFont(ofSize: 30.0 * zoomScale)
        let attribs: [NSAttributedString.Key : Any] = [.font: font, .foregroundColor: UIColor.red]
        //  transform the point
        let trPT: CGPoint = theTextPoint.applying(tr)
        //  attributed string at zoomed point-size
        let string = NSAttributedString(string: "Sample", attributes: attribs)
        string.draw(at: trPT)
        
    }
    
}

class TheBasicsVC: UIViewController {
    
    let drawView = BasicScalingView()
    
    // a label to put at the top to show the current zoomScale
    let infoLabel: UILabel = {
        let v = UILabel()
        v.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
        v.textAlignment = .center
        v.text = " "
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let slider = UISlider()
        
        drawView.backgroundColor = .black
        
        [slider, infoLabel, drawView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // slider at the top
            slider.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            // info label
            infoLabel.topAnchor.constraint(equalTo: slider.bottomAnchor, constant: 20.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            drawView.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 20.0),
            drawView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            drawView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            drawView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
            
        ])
        
        slider.minimumValue = 1.0
        slider.maximumValue = 20.0
        
        slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
        
        updateInfo()
    }
    
    func updateInfo() {
        infoLabel.text = String(format: "zoomScale: %0.3f", drawView.zoomScale)
        
    }
    @objc func sliderChanged(_ sender: UISlider) {
        drawView.zoomScale = CGFloat(sender.value)
        updateInfo()
    }
    
}

class DrawZoomBaseVC: UIViewController {
    
    let scrollView: UIScrollView = UIScrollView()
    
    // this will be a plain, clear UIView that we will use
    //  as the viewForZooming
    let zoomView = UIView()
    
    // this will be placed *behind* the scrollView
    //  in our subclasses, we'll set it to either
    //      Simple or Complex
    //  and we'll set its zoomScale and contentOffset
    //  to match the scrollView
    var drawView: UIView!
    
    // a label to put at the top to show the current zoomScale
    let infoLabel: UILabel = {
        let v = UILabel()
        v.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
        v.textAlignment = .center
        v.numberOfLines = 0
        v.text = "\n\n\n"
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemYellow
        
        [infoLabel, drawView, scrollView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        zoomView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(zoomView)
        
        drawView.backgroundColor = .black
        scrollView.backgroundColor = .clear
        zoomView.backgroundColor = .clear
        
        let g = view.safeAreaLayoutGuide
        let cg = scrollView.contentLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // info label at the top
            infoLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            scrollView.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 20.0),
            scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
            
            zoomView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
            zoomView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
            zoomView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
            zoomView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
            
            drawView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 0.0),
            drawView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 0.0),
            drawView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: 0.0),
            drawView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 0.0),
            
        ])
        
        scrollView.maximumZoomScale = 60.0
        scrollView.minimumZoomScale = 0.1
        scrollView.zoomScale = 1.0
        
        scrollView.indicatorStyle = .white
        
        scrollView.delegate = self
        
        infoLabel.isHidden = true
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // if we're using the ComplexDrawScaledView
        //  we *get* its size that was determined by
        //  it laying out its elements in its commonInit()
        
        // if we're using the SimpleDrawScaledView
        //  we set its size to the scroll view's frame size
        if let dv = drawView as? SimpleDrawScaledView {
            dv.virtualSize = scrollView.frame.size
            zoomView.widthAnchor.constraint(equalToConstant: dv.virtualSize.width).isActive = true
            zoomView.heightAnchor.constraint(equalToConstant: dv.virtualSize.height).isActive = true
        }
        else
        if let dv = drawView as? ComplexDrawScaledView {
            zoomView.widthAnchor.constraint(equalToConstant: dv.virtualSize.width).isActive = true
            zoomView.heightAnchor.constraint(equalToConstant: dv.virtualSize.height).isActive = true
        }
        
        // let auto-layout size the view before we update the info label
        DispatchQueue.main.async {
            self.updateInfoLabel()
        }
    }
    
    func updateInfoLabel() {
        infoLabel.text = String(format: "\nzoomView size: (%0.0f, %0.0f)\nzoomScale: %0.3f\n", zoomView.frame.width, zoomView.frame.height, scrollView.zoomScale)
    }
    
}

extension DrawZoomBaseVC: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if let dv = drawView as? SimpleDrawScaledView {
            dv.contentOffset = scrollView.contentOffset
        }
        else
        if let dv = drawView as? ComplexDrawScaledView {
            dv.contentOffset = scrollView.contentOffset
        }
    }
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        updateInfoLabel()
        if let dv = drawView as? SimpleDrawScaledView {
            dv.zoomScale = scrollView.zoomScale
        }
        else
        if let dv = drawView as? ComplexDrawScaledView {
            dv.zoomScale = scrollView.zoomScale
        }
    }
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return zoomView
    }
}

class SimpleVC: DrawZoomBaseVC {
    
    override func viewDidLoad() {
        drawView = SimpleDrawScaledView()
        super.viewDidLoad()
    }
    
}

class ComplexVC: DrawZoomBaseVC {
    
    override func viewDidLoad() {
        drawView = ComplexDrawScaledView()
        super.viewDidLoad()
    }
    
}

class SimpleDrawScaledView: UIView {
    
    private var _virtualSize: CGSize = .zero
    
    public var virtualSize: CGSize {
        set {
            _virtualSize = newValue
            
            // let's use a 120x80 rect, centered in the view bounds
            var theRect: CGRect = .init(x: 4.0, y: 4.0, width: 120.0, height: 80.0)
            theRect.origin = .init(x: (_virtualSize.width - theRect.width) * 0.5, y: (_virtualSize.height - theRect.height) * 0.5)
            
            // create a rect path
            theRectPath = UIBezierPath(rect: theRect)
            //  create an oval path (slightly inset)
            theOvalPath = UIBezierPath(ovalIn: theRect.insetBy(dx: 12.0, dy: 12.0))
            // we want to center the text in the rects, so
            //  get the mid-point of the rect
            theTextPoint = .init(x: theRect.midX, y: theRect.midY)
            
            setNeedsDisplay()
        }
        get {
            return _virtualSize
        }
    }
    
    public var zoomScale: CGFloat = 1.0 { didSet { setNeedsDisplay() } }
    public var contentOffset: CGPoint = .zero { didSet { setNeedsDisplay() } }
    
    private var theRectPath: UIBezierPath!
    private var theOvalPath: UIBezierPath!
    private var theTextPoint: CGPoint!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
    }
    
    override func draw(_ rect: CGRect) {
        
        // only draw if we've initialized the paths
        guard theRectPath != nil, theOvalPath != nil else { return }
        
        let tr = CGAffineTransform(translationX: -contentOffset.x, y: -contentOffset.y)
            .scaledBy(x: zoomScale, y: zoomScale)
        
        drawRect(insideRect: rect, withTransform: tr)
        drawOval(insideRect: rect, withTransform: tr)
        drawString(insideRect: rect, withTransform: tr)
        
    }
    
    func drawRect(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        if let path = theRectPath.copy() as? UIBezierPath {
            //  transform a copy of the rect path
            path.apply(tr)
            
            // only draw if visible
            if path.bounds.intersects(insideRect) {
                UIColor.green.set()
                path.lineWidth = 2.0 * zoomScale
                path.stroke()
            }
            
        }
    }
    
    func drawOval(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        if let path = theOvalPath.copy() as? UIBezierPath {
            //  transform a copy of the oval path
            path.apply(tr)
            
            // only draw if visible
            if path.bounds.intersects(insideRect) {
                UIColor.systemBlue.set()
                UIColor(white: 0.95, alpha: 1.0).setFill()
                path.lineWidth = 3.0 * zoomScale
                path.fill()
                path.stroke()
            }
        }
    }
    
    func drawString(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        // scale the font point-size
        let font: UIFont = .systemFont(ofSize: 30.0 * zoomScale)
        let attribs: [NSAttributedString.Key : Any] = [.font: font, .foregroundColor: UIColor.red]
        //  transform the point
        let trPT: CGPoint = theTextPoint.applying(tr)
        //  attributed string at zoomed point-size
        let string = NSAttributedString(string: "Sample", attributes: attribs)
        //  calculate the text rect
        let sz: CGSize = string.size()
        let r: CGRect = .init(x: trPT.x - sz.width * 0.5, y: trPT.y - sz.height * 0.5, width: sz.width, height: sz.height)
        // only draw if visible
        if r.intersects(insideRect) {
            string.draw(at: r.origin)
        }
    }
    
}

class ComplexDrawScaledView: UIView {
    
    // this will be set by the "rects" layout in commonInit()
    public var virtualSize: CGSize = .zero
    
    public var zoomScale: CGFloat = 1.0 { didSet { setNeedsDisplay() } }
    public var contentOffset: CGPoint = .zero { didSet { setNeedsDisplay() } }
    
    private let nCols: Int = 32
    private let nRows: Int = 40
    private let colWidth: CGFloat = 120.0
    private let rowHeight: CGFloat = 80.0
    private let colSpacing: CGFloat = 16.0
    private let rowSpacing: CGFloat = 16.0
    
    private let rectInset: CGSize = .init(width: 1.0, height: 1.0)
    private let ovalInset: CGSize = .init(width: 12.0, height: 12.0)
    
    private var theRectPaths: [UIBezierPath] = []
    private var theOvalPaths: [UIBezierPath] = []
    private var theTextPoints: [CGPoint] = []
    private var theBirdPaths: [UIBezierPath] = []
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        // let's create a "grid" of rects
        // every rect will be used to create a
        //  rect path - alternating between rect and roundedRect
        //  a centered oval path
        //  and a centered text point
        
        var r: CGRect = .init(x: 0.0, y: 0.0, width: colWidth, height: rowHeight)
        for row in 0..<nRows {
            for col in 0..<nCols {
                let rPath = (row + col) % 2 == 0
                ? UIBezierPath(roundedRect: r.insetBy(dx: rectInset.width, dy: rectInset.height), cornerRadius: 12.0)
                : UIBezierPath(rect: r.insetBy(dx: rectInset.width, dy: rectInset.height))
                theRectPaths.append(rPath)
                let oPath = UIBezierPath(ovalIn: r.insetBy(dx: ovalInset.width, dy: ovalInset.height))
                theOvalPaths.append(oPath)
                let pt: CGPoint = .init(x: r.midX, y: r.midY)
                theTextPoints.append(pt)
                r.origin.x += colWidth + colSpacing
            }
            r.origin.x = 0.0
            r.origin.y += rowHeight + rowSpacing
        }
        
        // our "virtual size"
        let w: CGFloat = theRectPaths.compactMap( { $0.bounds.maxX }).max()!
        let h: CGFloat = theRectPaths.compactMap( { $0.bounds.maxY }).max()!
        
        let sz: CGSize = .init(width: w, height: h)
        
        // let's use 100x100 SwiftyBird paths, arranged:
        //  - one each at 50-points from the corners
        //  - one each at 25% from the corners
        //  - one centered
        // so about like this:
        //  +--------------------+
        //  | x                x |
        //  |                    |
        //  |    x          x    |
        //  |                    |
        //  |         x          |
        //  |                    |
        //  |    x          x    |
        //  |                    |
        //  | x                x |
        //  +--------------------+
        
        let v: CGFloat = 100.0
        r = .init(x: 0.0, y: 0.0, width: v, height: v)
        
        r.origin = .init(x: 50.0, y: 50.0)
        theBirdPaths.append(SwiftyBird().path(inRect: r))

        r.origin = .init(x: sz.width - (v + 50.0), y: 50.0)
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: 50.0, y: sz.height - (v + 50.0))
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: sz.width - (v + 50.0), y: sz.height - (v + 50.0))
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: sz.width * 0.25 - v * 0.5, y: sz.height * 0.25 - v * 0.5)
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: sz.width * 0.75 - v * 0.5, y: sz.height * 0.25 - v * 0.5)
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: sz.width * 0.25 - v * 0.5, y: sz.height * 0.75 - v * 0.5)
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: sz.width * 0.75 - v * 0.5, y: sz.height * 0.75 - v * 0.5)
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        r.origin = .init(x: sz.width * 0.5 - v * 0.5, y: sz.height * 0.5 - v * 0.5)
        theBirdPaths.append(SwiftyBird().path(inRect: r))
        
        virtualSize = sz
        
    }
    
    override func draw(_ rect: CGRect) {
        
        let tr = CGAffineTransform(translationX: -contentOffset.x, y: -contentOffset.y)
            .scaledBy(x: zoomScale, y: zoomScale)
        
        drawRects(insideRect: rect, withTransform: tr)
        drawOvals(insideRect: rect, withTransform: tr)
        drawStrings(insideRect: rect, withTransform: tr)
        drawBirds(insideRect: rect, withTransform: tr)
        
    }
    
    private func drawRects(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        UIColor.green.setStroke()
        theRectPaths.forEach { pth in
            if let path = pth.copy() as? UIBezierPath {
                //  transform a copy of the path
                path.apply(tr)
                // only draw if visible
                if path.bounds.intersects(insideRect) {
                    path.lineWidth = 2.0 * zoomScale
                    path.stroke()
                }
            }
        }
    }
    private func drawOvals(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        UIColor.systemBlue.setStroke()
        UIColor(white: 0.95, alpha: 1.0).setFill()
        theOvalPaths.forEach { pth in
            if let path = pth.copy() as? UIBezierPath {
                //  transform a copy of the path
                path.apply(tr)
                // only draw if visible
                if path.bounds.intersects(insideRect) {
                    path.lineWidth = 3.0 * zoomScale
                    path.fill()
                    path.stroke()
                }
            }
        }
    }
    private func drawStrings(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        // scale the font point-size
        let font: UIFont = .systemFont(ofSize: 30.0 * zoomScale)
        let attribs: [NSAttributedString.Key : Any] = [.font: font, .foregroundColor: UIColor.red]
        for (i, pt) in theTextPoints.enumerated() {
            //  transform the point
            let trPT: CGPoint = pt.applying(tr)
            //  attributed string at zoomed point-size
            let string = NSAttributedString(string: "\(i+1)", attributes: attribs)
            //  calculate the text rect
            let sz: CGSize = string.size()
            let r: CGRect = .init(x: trPT.x - sz.width * 0.5, y: trPT.y - sz.height * 0.5, width: sz.width, height: sz.height)
            // only draw if visible
            if r.intersects(insideRect) {
                string.draw(at: r.origin)
            }
        }
    }
    private func drawBirds(insideRect: CGRect, withTransform tr: CGAffineTransform) {
        UIColor.yellow.setStroke()
        UIColor(red: 1.0, green: 0.6, blue: 0.3, alpha: 0.8).setFill()
        theBirdPaths.forEach { pth in
            if let path = pth.copy() as? UIBezierPath {
                // transform the path
                path.apply(tr)
                // only draw if visible
                if path.bounds.intersects(insideRect) {
                    path.lineWidth = 2.0 * zoomScale
                    path.fill()
                    path.stroke()
                }
            }
        }
    }

}

class SwiftyBird: NSObject {
    func path(inRect: CGRect) -> UIBezierPath {
        
        let thisShape = UIBezierPath()
        
        thisShape.move(to: CGPoint(x: 0.31, y: 0.94))
        thisShape.addCurve(to: CGPoint(x: 0, y: 0.64), controlPoint1: CGPoint(x: 0.18, y: 0.87), controlPoint2: CGPoint(x: 0.07, y: 0.76))
        thisShape.addCurve(to: CGPoint(x: 0.12, y: 0.72), controlPoint1: CGPoint(x: 0.03, y: 0.67), controlPoint2: CGPoint(x: 0.07, y: 0.7))
        thisShape.addCurve(to: CGPoint(x: 0.57, y: 0.72), controlPoint1: CGPoint(x: 0.28, y: 0.81), controlPoint2: CGPoint(x: 0.45, y: 0.8))
        thisShape.addCurve(to: CGPoint(x: 0.57, y: 0.72), controlPoint1: CGPoint(x: 0.57, y: 0.72), controlPoint2: CGPoint(x: 0.57, y: 0.72))
        thisShape.addCurve(to: CGPoint(x: 0.15, y: 0.23), controlPoint1: CGPoint(x: 0.4, y: 0.57), controlPoint2: CGPoint(x: 0.26, y: 0.39))
        thisShape.addCurve(to: CGPoint(x: 0.1, y: 0.15), controlPoint1: CGPoint(x: 0.13, y: 0.21), controlPoint2: CGPoint(x: 0.11, y: 0.18))
        thisShape.addCurve(to: CGPoint(x: 0.5, y: 0.49), controlPoint1: CGPoint(x: 0.22, y: 0.28), controlPoint2: CGPoint(x: 0.43, y: 0.44))
        thisShape.addCurve(to: CGPoint(x: 0.22, y: 0.09), controlPoint1: CGPoint(x: 0.35, y: 0.31), controlPoint2: CGPoint(x: 0.21, y: 0.08))
        thisShape.addCurve(to: CGPoint(x: 0.69, y: 0.52), controlPoint1: CGPoint(x: 0.46, y: 0.37), controlPoint2: CGPoint(x: 0.69, y: 0.52))
        thisShape.addCurve(to: CGPoint(x: 0.71, y: 0.54), controlPoint1: CGPoint(x: 0.7, y: 0.53), controlPoint2: CGPoint(x: 0.7, y: 0.53))
        thisShape.addCurve(to: CGPoint(x: 0.61, y: 0), controlPoint1: CGPoint(x: 0.77, y: 0.35), controlPoint2: CGPoint(x: 0.71, y: 0.15))
        thisShape.addCurve(to: CGPoint(x: 0.92, y: 0.68), controlPoint1: CGPoint(x: 0.84, y: 0.15), controlPoint2: CGPoint(x: 0.98, y: 0.44))
        thisShape.addCurve(to: CGPoint(x: 0.92, y: 0.7), controlPoint1: CGPoint(x: 0.92, y: 0.69), controlPoint2: CGPoint(x: 0.92, y: 0.7))
        thisShape.addCurve(to: CGPoint(x: 0.92, y: 0.7), controlPoint1: CGPoint(x: 0.92, y: 0.7), controlPoint2: CGPoint(x: 0.92, y: 0.7))
        thisShape.addCurve(to: CGPoint(x: 0.99, y: 1), controlPoint1: CGPoint(x: 1.00, y: 0.86), controlPoint2: CGPoint(x: 1, y: 1.00))
        thisShape.addCurve(to: CGPoint(x: 0.75, y: 0.93), controlPoint1: CGPoint(x: 0.92, y: 0.86), controlPoint2: CGPoint(x: 0.81, y: 0.9))
        thisShape.addCurve(to: CGPoint(x: 0.31, y: 0.94), controlPoint1: CGPoint(x: 0.64, y: 1.01), controlPoint2: CGPoint(x: 0.47, y: 1.00))
        thisShape.close()
        
        let tr = CGAffineTransform(translationX: inRect.minX, y: inRect.minY)
            .scaledBy(x: inRect.width, y: inRect.height)
        thisShape.apply(tr)
        
        return thisShape
    }
}

Edit - I put up a project at https://github.com/DonMag/VirtualZoom showing these examples. Also includes filling the "bird" path with a gradient.

DonMag
  • 69,424
  • 5
  • 50
  • 86