Search code examples
iosswiftscalingaspect-ratio

How To Scale The Contents Of A UIView To Fit A Destination Rectangle Whilst Maintaining The Aspect Ratio?


I am trying to solve a problem without success and am hoping someone could help.

I have looked for similar posts but haven't been able to find anything which solves my problem.

My Scenario is as follows: I have a UIView on which a number of other UIViews can be placed. These can be moved, scaled and rotated using gesture recognisers (There is no issue here). The User is able to change the Aspect Ratio of the Main View (the Canvas) and my problem is trying to scale the content of the Canvas to fit into the new destination size.

There are a number of posts with a similar theme e.g:

calculate new size and location on a CGRect

How to create an image of specific size from UIView

But these don't address the changing of ratios multiple times.

My Approach:

When I change the aspect ratio of the canvas, I make use of AVFoundation to calculate an aspect fitted rectangle which the subviews of the canvas should fit:

let sourceRectangleSize = canvas.frame.size

canvas.setAspect(aspect, screenSize: editorLayoutGuide.layoutFrame.size)
view.layoutIfNeeded()

let destinationRectangleSize = canvas.frame.size

let aspectFittedFrame = AVMakeRect(aspectRatio:sourceRectangleSize, insideRect: CGRect(origin: .zero, size: destinationRectangleSize))
ratioVisualizer.frame = aspectFittedFrame

Test Cases The Red frame is simply to visualise the Aspect Fitted Rectangle. As you can see whilst the aspect fitted rectangle is correct, the scaling of objects isn't working. This is especially true when I apply scale and rotation to the subviews (CanvasElement).

The logic where I am scaling the objects is clearly wrong:

@objc
private func setRatio(_ control: UISegmentedControl) {
  guard let aspect = Aspect(rawValue: control.selectedSegmentIndex) else { return }
  
  let sourceRectangleSize = canvas.frame.size
 
  canvas.setAspect(aspect, screenSize: editorLayoutGuide.layoutFrame.size)
  view.layoutIfNeeded()
 
  let destinationRectangleSize = canvas.frame.size
  
  let aspectFittedFrame = AVMakeRect(aspectRatio:sourceRectangleSize, insideRect: CGRect(origin: .zero, size: destinationRectangleSize))
  ratioVisualizer.frame = aspectFittedFrame
  
  let scale = min(aspectFittedFrame.size.width/canvas.frame.width, aspectFittedFrame.size.height/canvas.frame.height)
  
  for case let canvasElement as CanvasElement in canvas.subviews {
  
    canvasElement.frame.size = CGSize(
      width: canvasElement.baseFrame.width * scale,
      height: canvasElement.baseFrame.height * scale
    )
    canvasElement.frame.origin = CGPoint(
      x: aspectFittedFrame.origin.x + canvasElement.baseFrame.origin.x * scale,
      y:  aspectFittedFrame.origin.y + canvasElement.baseFrame.origin.y * scale
    )
  }
}

I am enclosing the CanvasElement Class as well if this helps:

final class CanvasElement: UIView {
  
  var rotation: CGFloat = 0
  var baseFrame: CGRect = .zero

  var id: String = UUID().uuidString
  
  // MARK: - Initialization
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    storeState()
    setupGesture()
  }
  
  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
  }
  
  // MARK: - Gesture Setup
  
  private func setupGesture() {
    let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGesture(_:)))
    let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(pinchGesture(_:)))
    let rotateGestureRecognizer = UIRotationGestureRecognizer(target: self, action: #selector(rotateGesture(_:)))
    addGestureRecognizer(panGestureRecognizer)
    addGestureRecognizer(pinchGestureRecognizer)
    addGestureRecognizer(rotateGestureRecognizer)
  }
  
  // MARK: - Touches
  
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesBegan(touches, with: event)
    moveToFront()
  }
  
  //MARK: - Gestures
  
  @objc
  private func panGesture(_ sender: UIPanGestureRecognizer) {
    let move = sender.translation(in: self)
    transform = transform.concatenating(.init(translationX: move.x, y: move.y))
    sender.setTranslation(CGPoint.zero, in: self)
    storeState()
  }
  
  @objc
  private func pinchGesture(_ sender: UIPinchGestureRecognizer) {
    transform = transform.scaledBy(x: sender.scale, y: sender.scale)
    sender.scale = 1
    storeState()
  }
  
  @objc
  private func rotateGesture(_ sender: UIRotationGestureRecognizer) {
    rotation += sender.rotation
    transform = transform.rotated(by: sender.rotation)
    sender.rotation = 0
    storeState()
  }
  
  // MARK: - Miscelaneous
  
  func moveToFront() {
    superview?.bringSubviewToFront(self)
  }
  
  public func rotated(by degrees: CGFloat) {
    transform = transform.rotated(by: degrees)
    rotation += degrees
  }
  
  func storeState() {
    print("""
    Element Frame = \(frame)
    Element Bounds = \(bounds)
    Element Center = \(center)
    """)
    baseFrame = frame
  }
}

Any help or advise, approaches, with some actual examples would be great. Im not expecting anyone to provide full source code, but something which I could use as a basis.

Thank you for taking the time to read my question.


Solution

  • Couple suggestions...

    First, when using your CanvasElement, panning doesn't work correctly if the view has been rotated.

    So, instead of using a translate transform to move the view, change the .center itself. In addition, when panning, we want to use the translation in the superview, not in the view itself:

    @objc
    func panGesture(_ gest: UIPanGestureRecognizer) {
        // change the view's .center instead of applying translate transform
        //  use translation in superview, not in self
        guard let superV = superview else { return }
        let translation = gest.translation(in: superV)
        center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
        gest.setTranslation(CGPoint.zero, in: superV)
    }
    

    Now, when we want to scale the subviews when the "Canvas" changes size, we can do this...

    We'll track the "previous" bounds and use the "new bounds" to calculate the scale:

    let newBounds: CGRect = bounds
        
    let scW: CGFloat = newBounds.size.width / prevBounds.size.width
    let scH: CGFloat = newBounds.size.height / prevBounds.size.height
        
    for case let v as CanvasElement in subviews {
        // reset transform before scaling / positioning
        let tr = v.transform
        v.transform = .identity
    
        let w = v.frame.width * scW
        let h = v.frame.height * scH
        let cx = v.center.x * scW
        let cy = v.center.y * scH
    
        v.frame.size = CGSize(width: w, height: h)
        v.center = CGPoint(x: cx, y: cy)
    
        // re-apply transform
        v.transform = tr
    }
    
    prevBounds = newBounds
    

    Here's a complete sample implementation. Please note: this is Example Code Only!!! It is not intended to be "Production Ready."

    import UIKit
    
    // MARK: enum to provide strings and aspect ratio values
    enum Aspect: Int, Codable, CaseIterable {
        case a1to1
        case a16to9
        case a3to2
        case a4to3
        case a9to16
        var stringValue: String {
            switch self {
            case .a1to1:
                return "1:1"
            case .a16to9:
                return "16:9"
            case .a3to2:
                return "3:2"
            case .a4to3:
                return "4:3"
            case .a9to16:
                return "9:16"
            }
        }
        var aspect: CGFloat {
            switch self {
            case .a1to1:
                return 1
            case .a16to9:
                return 9.0 / 16.0
            case .a3to2:
                return 2.0 / 3.0
            case .a4to3:
                return 3.0 / 4.0
            case .a9to16:
                return 16.0 / 9.0
            }
        }
    }
    
    class EditorView: UIView {
        // no code -
        //  just makes it easier to identify
        //  this view when debugging
    }
    
    // CanvasElement views will be added as subviews
    //  this handles the scaling / positioning when the bounds changes
    //  also (optionally) draws a grid (for use during development)
    class CanvasView: UIView {
        
        public var showGrid: Bool = true
    
        private let gridLayer: CAShapeLayer = CAShapeLayer()
        
        private var prevBounds: CGRect = .zero
        
        // MARK: init
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
    
            gridLayer.fillColor = UIColor.clear.cgColor
            gridLayer.strokeColor = UIColor.red.cgColor
            gridLayer.lineWidth = 1
            
            layer.addSublayer(gridLayer)
            
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
            
            // MARK: 10 x 10 grid
            if showGrid {
                // draw a grid on the inside of the bounds
                //  so the edges are not 1/2 point width
                let gridBounds: CGRect = bounds.insetBy(dx: 0.5, dy: 0.5)
                
                let path: UIBezierPath = UIBezierPath()
                
                let w: CGFloat = gridBounds.width / 10.0
                let h: CGFloat = gridBounds.height / 10.0
                
                var p: CGPoint = .zero
                
                p = CGPoint(x: gridBounds.minX, y: gridBounds.minY)
                for _ in 0...10 {
                    path.move(to: p)
                    path.addLine(to: CGPoint(x: p.x, y: gridBounds.maxY))
                    p.x += w
                }
                
                p = CGPoint(x: gridBounds.minX, y: gridBounds.minY)
                for _ in 0...10 {
                    path.move(to: p)
                    path.addLine(to: CGPoint(x: gridBounds.maxX, y: p.y))
                    p.y += h
                }
                
                gridLayer.path = path.cgPath
            }
            
            // MARK: update subviews
            // we only want to move/scale the subviews if
            //  the bounds has > 0 width and height and
            //  prevBounds has > 0 width and height and
            //  the bounds has changed
    
            guard bounds != prevBounds,
                  bounds.width > 0, prevBounds.width > 0,
                  bounds.height > 0, prevBounds.height > 0
            else { return }
    
            let newBounds: CGRect = bounds
            
            let scW: CGFloat = newBounds.size.width / prevBounds.size.width
            let scH: CGFloat = newBounds.size.height / prevBounds.size.height
            
            for case let v as CanvasElement in subviews {
                // reset transform before scaling / positioning
                let tr = v.transform
                v.transform = .identity
    
                let w = v.frame.width * scW
                let h = v.frame.height * scH
                let cx = v.center.x * scW
                let cy = v.center.y * scH
    
                v.frame.size = CGSize(width: w, height: h)
                v.center = CGPoint(x: cx, y: cy)
    
                // re-apply transform
                v.transform = tr
            }
    
            prevBounds = newBounds
        }
        
        override var bounds: CGRect {
            willSet {
                prevBounds = bounds
            }
        }
    }
    
    // self-contained Pan/Pinch/Rotate view
    //  set allowSimultaneous to TRUE to enable
    //  simultaneous gestures
    class CanvasElement: UIView, UIGestureRecognizerDelegate {
        
        public var allowSimultaneous: Bool = false
        
        // MARK: init
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            
            isUserInteractionEnabled = true
            isMultipleTouchEnabled = true
            
            let panG = UIPanGestureRecognizer(target: self, action: #selector(panGesture(_:)))
            let pinchG = UIPinchGestureRecognizer(target: self, action: #selector(pinchGesture(_:)))
            let rotateG = UIRotationGestureRecognizer(target: self, action: #selector(rotateGesture(_:)))
            
            [panG, pinchG, rotateG].forEach { g in
                g.delegate = self
                addGestureRecognizer(g)
            }
            
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            super.touchesBegan(touches, with: event)
            // unwrap optional superview
            guard let superV = superview else { return }
            superV.bringSubviewToFront(self)
        }
        
        // MARK: UIGestureRecognizer Methods
        
        @objc
        func panGesture(_ gest: UIPanGestureRecognizer) {
            // change the view's .center instead of applying translate transform
            //  use translation in superview, not in self
            guard let superV = superview else { return }
            let translation = gest.translation(in: superV)
            center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
            gest.setTranslation(CGPoint.zero, in: superV)
        }
        
        @objc
        func pinchGesture(_ gest: UIPinchGestureRecognizer) {
            // apply scale transform
            transform = transform.scaledBy(x: gest.scale, y: gest.scale)
            gest.scale = 1
        }
        
        @objc
        func rotateGesture(_ gest : UIRotationGestureRecognizer) {
            // apply rotate transform
            transform = transform.rotated(by: gest.rotation)
            gest.rotation = 0
        }
        
        // MARK: UIGestureRecognizerDelegate Methods
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
            return allowSimultaneous
        }
        
    }
    
    // example view controller
    //  Aspect Ratio segmented control
    //      changes the Aspect Ratio of the Editor View
    //  includes triple-tap gesture to cycle through
    //      3 "starting subview" layouts
    class ViewController: UIViewController, UIGestureRecognizerDelegate {
        
        let editorView: EditorView = {
            let v = EditorView()
            v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        
        let canvasView: CanvasView = {
            let v = CanvasView()
            v.backgroundColor = .yellow
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        
        // segmented control for selecting Aspect Ratio
        let aspectRatioSeg: UISegmentedControl = {
            let v = UISegmentedControl()
            v.setContentCompressionResistancePriority(.required, for: .vertical)
            v.setContentHuggingPriority(.required, for: .vertical)
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        
        // this will be changed by the Aspect Ratio segmented control
        var evAspectConstraint: NSLayoutConstraint!
        
        // used to cycle through intitial subviews layout
        var layoutMode: Int = 0
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = UIColor(white: 0.99, alpha: 1.0)
            
            // container view for laying out editor view
            let containerView: UIView = {
                let v = UIView()
                v.backgroundColor = .cyan
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
    
            // setup the aspect ratio segmented control
            for (idx, m) in Aspect.allCases.enumerated() {
                aspectRatioSeg.insertSegment(withTitle: m.stringValue, at: idx, animated: false)
            }
            
            // add canvas view to editor view
            editorView.addSubview(canvasView)
            
            // add editor view to container view
            containerView.addSubview(editorView)
            
            // add container view to self's view
            view.addSubview(containerView)
            
            // add UI Aspect Ratio segmented control to self's view
            view.addSubview(aspectRatioSeg)
            
            // always respect the safe area
            let safeG = view.safeAreaLayoutGuide
            
            // editor view inset from container view sides
            let evInset: CGFloat = 0
            
            // canvas view inset from editor view sides
            let cvInset: CGFloat = 0
            
            // these sets of constraints will make the Editor View and the Canvas View
            //  as large as their superviews (with "Inset Edge Padding" if set above)
            //  while maintaining aspect ratios and centering
            let evMaxW = editorView.widthAnchor.constraint(lessThanOrEqualTo: containerView.widthAnchor, constant: -evInset)
            let evMaxH = editorView.heightAnchor.constraint(lessThanOrEqualTo: containerView.heightAnchor, constant: -evInset)
            
            let evW = editorView.widthAnchor.constraint(equalTo: containerView.widthAnchor)
            let evH = editorView.heightAnchor.constraint(equalTo: containerView.heightAnchor)
            evW.priority = .required - 1
            evH.priority = .required - 1
            
            let cvMaxW = canvasView.widthAnchor.constraint(lessThanOrEqualTo: editorView.widthAnchor, constant: -cvInset)
            let cvMaxH = canvasView.heightAnchor.constraint(lessThanOrEqualTo: editorView.heightAnchor, constant: -cvInset)
            
            let cvW = canvasView.widthAnchor.constraint(equalTo: editorView.widthAnchor)
            let cvH = canvasView.heightAnchor.constraint(equalTo: editorView.heightAnchor)
            cvW.priority = .required - 1
            cvH.priority = .required - 1
            
            // editor view starting aspect ratio
            //  this is changed by the segmented control
            let editorAspect: Aspect = .a1to1
            aspectRatioSeg.selectedSegmentIndex = editorAspect.rawValue
            evAspectConstraint = editorView.heightAnchor.constraint(equalTo: editorView.widthAnchor, multiplier: editorAspect.aspect)
    
            // we can set the Aspect Ratio of the CanvasView here
            //  it will maintain its Aspect Ratio independent of
            //  the Editor View's Aspect Ratio
            let canvasAspect: Aspect = .a1to1
    
            NSLayoutConstraint.activate([
                containerView.topAnchor.constraint(equalTo: safeG.topAnchor),
                containerView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor),
                containerView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor),
                
                editorView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
                editorView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
                evMaxW, evMaxH,
                evW, evH,
                evAspectConstraint,
                
                canvasView.centerXAnchor.constraint(equalTo: editorView.centerXAnchor),
                canvasView.centerYAnchor.constraint(equalTo: editorView.centerYAnchor),
                cvMaxW, cvMaxH,
                cvW, cvH,
                canvasView.heightAnchor.constraint(equalTo: canvasView.widthAnchor, multiplier: canvasAspect.aspect),
    
                aspectRatioSeg.topAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8.0),
                aspectRatioSeg.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -8.0),
                aspectRatioSeg.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
                aspectRatioSeg.widthAnchor.constraint(greaterThanOrEqualTo: safeG.widthAnchor, multiplier: 0.5),
                aspectRatioSeg.widthAnchor.constraint(lessThanOrEqualTo: safeG.widthAnchor),
            ])
            
            aspectRatioSeg.addTarget(self, action: #selector(aspectRatioSegmentChanged(_:)), for: .valueChanged)
            
            // triple-tap anywhere to "reset" the 3 subviews
            //  cycling between starting sizes/positions
            let tt = UITapGestureRecognizer(target: self, action: #selector(resetCanvas))
            tt.numberOfTapsRequired = 3
            tt.delaysTouchesEnded = false
            view.addGestureRecognizer(tt)
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            
            // we don't have the frames in viewDidLoad,
            //  so wait until now to add the CanvasElement views
            resetCanvas()
        }
        
        @objc func resetCanvas() {
            
            canvasView.subviews.forEach { v in
                v.removeFromSuperview()
            }
    
            // add 3 views to the canvas
    
            let v1 = CanvasElement()
            v1.backgroundColor = .systemYellow
    
            let v2 = CanvasElement()
            v2.backgroundColor = .systemGreen
    
            let v3 = CanvasElement()
            v3.backgroundColor = .systemBlue
    
            // default size of subviews is 2/10ths the width of the canvas
            let w: CGFloat = canvasView.bounds.width * 0.2
            
            [v1, v2, v3].forEach { v in
                v.frame = CGRect(x: 0, y: 0, width: w, height: w)
                canvasView.addSubview(v)
                // if we want to allow simultaneous gestures
                //  i.e. pan/scale/rotate all at the same time
                //v.allowSimultaneous = true
            }
            
            switch (layoutMode % 3) {
            case 1:
                //  top-left corner
                //  center at 1.5 times the size
                //  bottom-right corner
                v1.frame.origin = CGPoint(x: 0, y: 0)
                v2.frame.size = CGSize(width: w * 1.5, height: w * 1.5)
                v2.center = CGPoint(x: canvasView.bounds.midX, y: canvasView.bounds.midY)
                v3.center = CGPoint(x: canvasView.bounds.maxX - w * 0.5, y: canvasView.bounds.maxY - w * 0.5)
                ()
            case 2:
                // different sized views
                v1.frame = CGRect(x: 0, y: 0, width: w * 0.5, height: w)
                v2.frame.size = CGSize(width: w, height: w)
                v2.center = CGPoint(x: canvasView.bounds.midX, y: canvasView.bounds.midY)
                v3.frame.size = CGSize(width: w, height: w * 0.5)
                v3.center = CGPoint(x: canvasView.bounds.maxX - v3.frame.width * 0.5, y: canvasView.bounds.maxY - v3.frame.height * 0.5)
                ()
            default:
                //  on a "diagonal"
                //  starting at top-left corner
                v1.frame.origin = CGPoint(x: 0, y: 0)
                v2.frame.origin = CGPoint(x: w, y: w)
                v3.frame.origin = CGPoint(x: w * 2, y: w * 2)
                ()
            }
            
            layoutMode += 1
        }
    
        @objc func aspectRatioSegmentChanged(_ sender: Any?) {
            if let seg = sender as? UISegmentedControl,
               let r = Aspect.init(rawValue: seg.selectedSegmentIndex)
            {
                evAspectConstraint.isActive = false
                evAspectConstraint = editorView.heightAnchor.constraint(equalTo: editorView.widthAnchor, multiplier: r.aspect)
                evAspectConstraint.isActive = true
            }
        }
    
    }
    

    Some sample screenshots...

    • Yellow is the Canvas view... with optional red 10x10 grid
    • Gray is the Editor view... this is the view that changes Aspect Ratio
    • Cyan is the "Container" view.... Editor view fits/centers itself

    enter image description here

    enter image description here

    enter image description here

    Note that the Canvas view can be set to something other than a square (1:1 ratio). For example, here it's set to 9:16 ratio -- and maintains its Aspect Ratio independent of the Editor view Aspect Ratio:

    enter image description here

    enter image description here

    enter image description here

    With this example controller, triple-tap anywhere to cycle through 3 "starting layouts":

    enter image description here