5

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.

BlackMirrorz
  • 7,217
  • 2
  • 20
  • 31
  • It's really not clear what you're going for... do you want the subviews **scaled and positioned** to the new aspect ratio and size? Like this? https://i.stack.imgur.com/64rQF.png – DonMag Feb 11 '22 at 21:43
  • @DonMag What you have done looks correct, except as per the question the idea is it is scaled maintaining the aspect ratio. – BlackMirrorz Feb 12 '22 at 03:48
  • I'm still not clear on your goal... In the image you posted -- the gray rectangle is being set to your Aspect Ratio selection? And the red "outline" is supposed to **maintain** a 1:1 ratio, as in the 16:9 image? Or, the red "outline" is supposed to match the selected Ratio, and it's subview are supposed to "inherit" the ratio? Maybe if you add a couple images of how it ***should*** look? – DonMag Feb 12 '22 at 14:49
  • Still trying to guess what you really want to do. Take a look at this: https://imgur.com/a/89JodfQ ... Three "modes" .... A: "container" changes aspect ratio, "canvas" scales, stays centered, and maintains 1:1 ratio. B: "container" changes aspect ratio, "canvas" scales, stays centered, and maintains 16:9 ratio. C: "**container**" maintains 1:1 aspect ratio, "**canvas**" scales, stays centered, changes aspect ratio and its subviews change aspect ratio. – DonMag Feb 14 '22 at 17:03
  • Looking at the images. I believe A is the correct logic :) The objects should always maintain there aspect ratio. So if a square is on the canvas it will always be a square, but fitted into the aspect ratio of the canvas if that makes sense? I appreciate your effort on this, and apologies if its not entirely clear what Im after. – BlackMirrorz Feb 15 '22 at 00:42
  • So... to try and make sense of this... You have an "Editor View" (the gray view in your posted images) ... you have a "Canvas View" (the outline-red-frame) ... and you have "Canvas Subviews" (the red squares). Your **Editor** view *may change size / aspect ratio*, and your **Canvas** view should ***keep its aspect ratio***, sized and centered in the **Editor** view ... and the **Subviews** should ***keep their aspect ratios***, sized and positioned when the **Canvas** size changes? – DonMag Feb 15 '22 at 20:43
  • @DonMag Yes, that's correct :) – BlackMirrorz Feb 16 '22 at 00:29

3 Answers3

1

Here are a few thoughts and findings while playing around with this

1. Is the right scale factor being used?

The scaling you use is a bit custom and cannot be compared directly to the examples which has just 1 scale factor like 2 or 3. However, your scale factor has 2 dimensions but I see you compensate for this to get the minimum of the width and height scaling:

let scale = min(aspectFittedFrame.size.width / canvas.frame.width,
                aspectFittedFrame.size.height / canvas.frame.height)

In my opinion, I don't think this is the right scale factor. To me this compares new aspectFittedFrame with the new canvas frame

Aspect Ratio UIView translation scaling rotation Swift ios

when actually I believe the right scaling factor is to compare the new aspectFittedFrame with the previous canvas frame

let scale
    = min(aspectFittedFrame.size.width / sourceRectangleSize.width,
          aspectFittedFrame.size.height / sourceRectangleSize.height)

UIView translate scale rotate aspect ratio Swift ios

2. Is the scale being applied on the right values?

If you notice, the first order from 1:1 to 16:9 works quite well. However after that it does not seem to work and I believe the issue is here:

for case let canvasElement as CanvasElement in strongSelf.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
    )
}

The first time, the scale works well because canvas and the canvas elements are being scaled in sync or mapped properly:

Swift ios UIView scaling transformation rotation translation

However, if you go beyond that, because you are always scaling based on the base values your aspect ratio frame and your canvas elements are out of sync

UIView scaling transformation rotation translation Swift ios

So in the example of 1:1 -> 16:9 -> 3:2

  • Your viewport has been scaled twice 1:1 -> 16:9 and from 16:9 -> 3:2
  • Whereas your elements are scaled once each time, 1:1 -> 16:9 and 1:1 -> 3:2 because you always scale from the base values

So I feel to see the values within the red viewport, you need to apply the same continuous scaling based on the previous view rather than the base view.

Just for an immediate quick fix, I update the base values of the canvas element after each change in canvas size by calling canvasElement.storeState():

for case let canvasElement as CanvasElement in strongSelf.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 added this
    canvasElement.storeState()
}

The result is perhaps closer to what you want ?

UIView Scaling Translation Rotation Aspect Ratio Swift iOS

Final thoughts

While this might fix your issue, you will notice that it is not possible to come back to the original state as at each step a transformation is applied.

A solution could be to store the current values mapped to a specific viewport aspect ratio and calculate the right sizes for the others so that if you needed to get back to the original, you could do that.

Shawn Frank
  • 4,381
  • 2
  • 19
  • 29
  • It can be solved *mostly* using constraints: setup: view B embedded into view A constraints A.centerX = B.centerX A.centerY = B.centerY A.top < B.top A.leading < B.leading const = B.width/B.height where "const" is aspect ratio of B Author need to assign it using a bit of code – Krypt Feb 09 '22 at 16:37
0

Maybe you can make the three rectangles in a view. And then you can keep the aspect-ratio for the view.

If you are using autolayout and Snapkit. The constrains maybe like this:

view.snp.makeConstraints { make in
  make.width.height.lessThanOrEqualToSuperview()
  make.centerX.centerY.equalToSuperview()
  make.width.equalTo(view.snp.height)
  make.width.height.equalToSuperview().priority(.high)
}

So this view will be aspect-fit in superview.

Back to children in this view. If you want to scale every child when view's frame changed, you should add contrains too. Or you can use autoresizingMask, it maybe simpler.

If you didn't want to use autolayout. Maybe you can try transform. When you transform some view, the children in this view will be changed too.

// The scale depends on the aspect-ratio of superview.
view.transform = CGAffineTransformMakeScale(0.5, 0.5);
user2027712
  • 379
  • 4
  • 9
0

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

DonMag
  • 69,424
  • 5
  • 50
  • 86