Search code examples
iosswiftcore-graphics

How to increase the scale of a shape based on more than 1 CGPoints?


I'm adding a new feature for a drawing app library and I'm implementing a pinch to resize feature for it. For example I have a Line shape created by connecting 2 CGPoints. I want to increase the size of the shape by using UIPinchGestureRecognizer since it has the scale property. So far what I've done is this:

// The startPoint is the point of the first finger of the gesture, and the endPoint is the second finger.
// The scale value is from the UIPinchGestureRecognizer class
func handlePinch(startPoint: CGPoint, endPoint: CGPoint, scale: CGFloat) {
  ...

  var castedShape = SomeShapeWithTwoPoints...
  let transform = CGAffineTransform(scaleX: scale, y: scale)
  castedShape.a = castedShape.a.applying(transform)
  castedShape.b = castedShape.b.applying(transform)

  ...
}

But unfortunately, it doesn't behave as I expect it to (I would say the calculated points are too big and it positioned the points outside of the view). Since I'm not sure where did I do wrong, My question is how do I get the updated CGPoints based on the scale value of the pinch gesture the correct way?

EDIT:

For example, I have the first line before the gesture was done. It has 2 points A and B. When I do the gesture, I want the line to be longer while keeping the position like this. In order to achieve this, I want to calculate the A and B points based on the scale of the pinch gesture (I just need the new points after the gesture was done). Is there any way to do this?

Line diagram


Solution

  • The problem you're running into when scaling with a CGAffineTransform is that the transform is relative to 0,0.

    So, if you have points at:

    enter image description here

    a 2.0 scaling will result in:

    enter image description here

    Which is, obviously, not what you're going for -- you want the distance between the points to grow (or shrink) based on the scale factor.

    There are several ways to do this...

    Let's use some math.

    We'll start with two points:

    enter image description here

    As we know, any two points can be used to define a line segment:

    enter image description here

    Thanks to Pythagoras, we can easily get the length of the line -- and easily find the center-point on that line:

    enter image description here

    Now, what else can we do with a line and its center-point? We can define a circle:

    enter image description here

    and the circle's radius:

    enter image description here

    Now, if we want to "scale the points by 2.0", we can scale the radius to define a new circle:

    enter image description here

    and, with just a little more math, we can get the angle of the line and then "find the point on the circle":

    enter image description here

    and we've moved the two points away from each other, by the scale factor, keeping them on the original line.

    Here's a quick example...

    We'll start with some "convenience" CGPoint funcs:

    extension CGPoint {
        
        static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)
            return CGPoint(x: x, y: y)
        }
        
        func angleToPoint(otherPoint: CGPoint) -> CGFloat {
            let originX = otherPoint.x - self.x
            let originY = otherPoint.y - self.y
            return atan2(originY, originX)
        }
        
        func distanceToPoint(otherPoint: CGPoint) -> CGFloat {
            return sqrt(pow((otherPoint.x - x), 2) + pow((otherPoint.y - y), 2))
        }
        
        func centerBetween(otherPoint: CGPoint) -> CGPoint {
            let xDiff: CGFloat = otherPoint.x - x
            let yDiff: CGFloat = otherPoint.y - y
            return .init(x: x + xDiff * 0.5, y: y + yDiff * 0.5)
        }
        
        func scaleOnLineTo(otherPoint: CGPoint, scale: CGFloat) -> (CGPoint, CGPoint) {
            let p1: CGPoint = self
            let p2: CGPoint = otherPoint
            // get center point between p1,p2
            let center: CGPoint = p1.centerBetween(otherPoint: p2)
            // get radius (distance between center,p1)
            let radius: CGFloat = center.distanceToPoint(otherPoint: p1)
            // angle (radians) from center to p1
            let a1: CGFloat = center.angleToPoint(otherPoint: p1)
            // angle (radians) from center to p2
            let a2: CGFloat = center.angleToPoint(otherPoint: p2)
            // new p1 is point on circle with radius * scale
            let scaledP1: CGPoint = CGPoint.pointOnCircle(center: center, radius: radius * scale, angle: a1)
            // new p2 is point on circle with radius * scale
            let scaledP2: CGPoint = CGPoint.pointOnCircle(center: center, radius: radius * scale, angle: a2)
            return (scaledP1, scaledP2)
        }
        
    }
    

    a simple "round" view:

    class PointView: UIView {
        override func layoutSubviews() {
            super.layoutSubviews()
            layer.cornerRadius = bounds.width * 0.5
        }
    }
    

    and a demonstration:

    class PinchExampleVC: UIViewController {
        
        let vP1 = PointView()
        let vP2 = PointView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            vP1.backgroundColor = .red
            vP2.backgroundColor = .blue
            
            vP1.frame.size = .init(width: 20.0, height: 20.0)
            vP2.frame.size = vP1.frame.size
            
            view.addSubview(vP1)
            view.addSubview(vP2)
            
            let pg = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
            view.addGestureRecognizer(pg)
            
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            
            vP1.center = .init(x: view.bounds.midX + 40.0, y: view.bounds.midY - 80.0)
            vP2.center = .init(x: view.bounds.midX - 40.0, y: view.bounds.midY + 60.0)
            
        }
        
        @objc func handlePinch(_ gestureRecognizer : UIPinchGestureRecognizer) {
            
            if gestureRecognizer.state == .began || gestureRecognizer.state == .changed {
                let (xp1, xp2) = vP1.center.scaleOnLineTo(otherPoint: vP2.center, scale: gestureRecognizer.scale)
                vP1.center = xp1
                vP2.center = xp2
                gestureRecognizer.scale = 1.0
            }
            
        }
        
    }
    

    Looks like this:

    enter image description here

    As I said, there are several ways to do this. Depending on what else you might need to do -- for example, if you need to scale multiple points, a bezier path, etc -- you'd need to investigate transforms a bit more.

    But, for your specific question, this should do the job.