Search code examples
iosswiftuiviewuiscrollviewuibezierpath

Scale UIView elements


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?


Solution

  • 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.