How to ensure CAShapeLayer resizes to fit in UIView
Asked Answered
F

2

6

I am currently converting an MKPolyline to a BezierPath then a CAShapeLayer then adding the layer as a sublayer to a UIView. Currently struggling to ensure the path is not drawn outside the bounds of the UIView. I do not want to mask and have part of the path dissapear, but rather ensure that every point is resized and positioned in the center of the UIView.

func addPathToView() {
    guard let path = createPath(onView: polylineView) else { return }
    path.fit(into: polylineView.bounds).moveCenter(to: polylineView.center).fill()
    path.lineWidth     = 3.0
    path.lineJoinStyle = .round

    guard let layer  = createCAShapeLayer(fromBezierPath: path) else { return }
    layer.path       = getScaledPath(fromPath: path, layer: layer)
    layer.frame      = polylineView.bounds
    layer.position.x = polylineView.bounds.minX
    layer.position.y = polylineView.bounds.minY

    polylineView.layer.addSublayer(layer)
}

func createCAShapeLayer( fromBezierPath path: UIBezierPath? ) -> CAShapeLayer? {
    guard let path = path else { print("No Path"); return nil }
    let pathLayer = CAShapeLayer(path: path, lineColor: UIColor.red, fillColor: UIColor.clear)
    return pathLayer
}

func createPath( onView view: UIView? ) -> UIBezierPath? {
    guard let polyline = Polyline().createPolyline(forLocations: locations) else { print("No Polyline"); return nil }
    guard let points   = convertMapPointsToCGPoints(fromPolyline: polyline) else { print("No CGPoints"); return nil }

    let path = UIBezierPath(points: points)

    return path
}

func convertMapPointsToCGPoints( fromPolyline polyline: MKPolyline? ) -> [CGPoint]? {
    guard let polyline = polyline else { print( "No Polyline"); return nil }

    let mapPoints = polyline.points()

    var points = [CGPoint]()

    for point in 0..<polyline.pointCount {
        let coordinate = MKCoordinateForMapPoint(mapPoints[point])
        points.append(mapView.convert(coordinate, toPointTo: view))
    }

    return points
}

func getScaledPath( fromPath path: UIBezierPath, layer: CAShapeLayer ) -> CGPath? {
    let boundingBox = path.cgPath.boundingBoxOfPath

    let boundingBoxAspectRatio = boundingBox.width / boundingBox.height
    let viewAspectRatio = polylineView.bounds.size.width / polylineView.bounds.size.height

    let scaleFactor: CGFloat
    if (boundingBoxAspectRatio > viewAspectRatio) {
        // Width is limiting factor
        scaleFactor = polylineView.bounds.size.width / boundingBox.width
    } else {
        // Height is limiting factor
        scaleFactor = polylineView.bounds.size.height/boundingBox.height
    }

    var affineTransorm = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
    let transformedPath = path.cgPath.copy(using: &affineTransorm)

    guard let tPath = transformedPath else { print ("nope"); return nil }

    return tPath
}

extension UIBezierPath
{
    func moveCenter(to:CGPoint) -> Self{
        let bound  = self.cgPath.boundingBox
        let center = bounds.center

        let zeroedTo = CGPoint(x: to.x-bound.origin.x, y: to.y-bound.origin.y)
        let vector = center.vector(to: zeroedTo)

        offset(to: CGSize(width: vector.dx, height: vector.dy))
        return self
    }

    func offset(to offset:CGSize) -> Self{
        let t = CGAffineTransform(translationX: offset.width, y: offset.height)
        applyCentered(transform: t)
        return self
    }

    func fit(into:CGRect) -> Self{
        let bounds = self.cgPath.boundingBox

        let sw     = into.size.width/bounds.width
        let sh     = into.size.height/bounds.height
        let factor = min(sw, max(sh, 0.0))

        return scale(x: factor, y: factor)
    }

    func scale(x:CGFloat, y:CGFloat) -> Self{
        let scale = CGAffineTransform(scaleX: x, y: y)
        applyCentered(transform: scale)
        return self
    }

    func applyCentered(transform: @autoclosure () -> CGAffineTransform ) -> Self{
        let bound  = self.cgPath.boundingBox
        let center = CGPoint(x: bound.midX, y: bound.midY)
        var xform  = CGAffineTransform.identity

        xform = xform.concatenating(CGAffineTransform(translationX: -center.x, y: -center.y))
        xform = xform.concatenating(transform())
        xform = xform.concatenating( CGAffineTransform(translationX: center.x, y: center.y))
        apply(xform)

        return self
    }
}

extension UIBezierPath
{
    convenience init(points:[CGPoint])
    {
        self.init()

        //connect every points by line.
        //the first point is start point
        for (index,aPoint) in points.enumerated()
        {
            if index == 0 {
                self.move(to: aPoint)
            }
            else {
                self.addLine(to: aPoint)
            }
        }
    }
}

//2. To create layer use this extension

extension CAShapeLayer
{
    convenience init(path:UIBezierPath, lineColor:UIColor, fillColor:UIColor)
    {
        self.init()
        self.path = path.cgPath
        self.strokeColor = lineColor.cgColor
        self.fillColor = fillColor.cgColor
        self.lineWidth = path.lineWidth

        self.opacity = 1
        self.frame = path.bounds
    }
}

enter image description here

Felishafelita answered 7/2, 2017 at 9:13 Comment(6)
why do you offset in your moveCenter function?Hesson
@Hesson the UIBezierPathExtension was an attempt found online to solve my problem link Currently I feel like I am just putting together parts of different puzzles and barely making something work which is why I came to stack overflow for help! Can't pinpoint why it is that I cant simply resize the layer, set its frame to the bounds of the uiview and be done.Felishafelita
have you tried commenting out the part about the offset?Hesson
@Hesson yes, I feel as if I can approach the bezierpath positioning in a completely different manner. Just not aware of the best approachFelishafelita
If you want, I can post an exemple on how I use BezierPath and CAShapeLayer to draw to a given scale which I don't know in advance. However the exemple won't contain anything about MKPolylineHesson
@Hesson That would be awesome! anything to help me wrap my head around how to approach this. Thank youFelishafelita
H
1

Here is an approach I use to scale a UIBezierPath: I will use original (your MKPolyline size, my original data) and final (the receiving view size, how it will be displayed).

1.Calculate the original amplitude (for me it was just the height but for you it will be the width as well)

2.Write a function to scale your original data to the new X and Y axis scales (for a point position it would look like this):

func scaleValueToYAxis(_ value: Double) -> CGFloat {
    return finalHeight - CGFloat(value) / originalYAmplitude) * finalHeight
}

func scaleValueToXAxis(_ value: Double) -> CGFloat {
     return finalWidth - CGFloat(value) / originalXAmplitude) * finalWidth

}

3.Start drawing

let path = UIBezierPath()
let path.move(to: CGPoint(x: yourOriginForDrawing, y: yourOriginForDrawing)) // final scale position

path.addLine(to: CGPoint(x: nextXPoint, y: nextYPoint)) // this is not relevant for you as you don't draw point by point
// what is important here is the fact that you take your original
//data X and Y and make them go though your scale functions 

let layer = CAShapeLayer()
let layer.path = path.cgPath
let layer.lineWidth = 1.0
let layer.strokeColor = UIColor.black

yourView.layer.addSublayer(layer)

As you can see the logic about drawing from MKPolyline remains to be done. What does matter is that when you "copy" the polyline you move(to: ) the right point to do it. This is why i'm thinking you don't have the right offset

Hesson answered 7/2, 2017 at 10:49 Comment(5)
Still struggling to getting a working output. Slightly confused on " original data X and Y and make them go through your scale functions " I added two extensions to the bottom of the code to show How I create the BezierPath and CAShapeLayer. Thanks for the help so far and hopefully my added details could hone in on my issue @HessonFelishafelita
your fist method seems good (you do have an array of points then?) Your shouldn't set a frame to the CAShapeLayer. The fact of adding it to a specific view will give it the bounds of the view already. You are missing one last thing, it's the scaling. Do you have an array for the CGPoint of your MKPolyline or not?Hesson
I do have a CGPoint Array being populated with the MapPoints. convertMapPointsToCGPoints(_:) I think everything there is correct. So it may just be the scaling?Felishafelita
I'm pretty sure it is. You should get the width and the height of the view in which you have your map points and then with proportionality look at how 1pt in that space equals to 1pt in you new spaceHesson
yes! took me a while, not sure why but also I was using polylineView.center rather than polylineView.bounds.center which was also a factor in alignment. Not sure entirely why yet, but now works as desired!Felishafelita
E
4

A UIBezierPath can be scaled just like a CGRect, CGPoint or 'CGSize' using an CGAffineTransform. 🐙

// calculate the scale
//
let scaleWidth  = toSize.width / fromSize.width
let scaleHeight = toSize.height / fromSize.height

// re-scale the path
//
path.apply(CGAffineTransform(scaleX: scaleWidth, y: scaleHeight))
Everyplace answered 12/6, 2019 at 11:31 Comment(0)
H
1

Here is an approach I use to scale a UIBezierPath: I will use original (your MKPolyline size, my original data) and final (the receiving view size, how it will be displayed).

1.Calculate the original amplitude (for me it was just the height but for you it will be the width as well)

2.Write a function to scale your original data to the new X and Y axis scales (for a point position it would look like this):

func scaleValueToYAxis(_ value: Double) -> CGFloat {
    return finalHeight - CGFloat(value) / originalYAmplitude) * finalHeight
}

func scaleValueToXAxis(_ value: Double) -> CGFloat {
     return finalWidth - CGFloat(value) / originalXAmplitude) * finalWidth

}

3.Start drawing

let path = UIBezierPath()
let path.move(to: CGPoint(x: yourOriginForDrawing, y: yourOriginForDrawing)) // final scale position

path.addLine(to: CGPoint(x: nextXPoint, y: nextYPoint)) // this is not relevant for you as you don't draw point by point
// what is important here is the fact that you take your original
//data X and Y and make them go though your scale functions 

let layer = CAShapeLayer()
let layer.path = path.cgPath
let layer.lineWidth = 1.0
let layer.strokeColor = UIColor.black

yourView.layer.addSublayer(layer)

As you can see the logic about drawing from MKPolyline remains to be done. What does matter is that when you "copy" the polyline you move(to: ) the right point to do it. This is why i'm thinking you don't have the right offset

Hesson answered 7/2, 2017 at 10:49 Comment(5)
Still struggling to getting a working output. Slightly confused on " original data X and Y and make them go through your scale functions " I added two extensions to the bottom of the code to show How I create the BezierPath and CAShapeLayer. Thanks for the help so far and hopefully my added details could hone in on my issue @HessonFelishafelita
your fist method seems good (you do have an array of points then?) Your shouldn't set a frame to the CAShapeLayer. The fact of adding it to a specific view will give it the bounds of the view already. You are missing one last thing, it's the scaling. Do you have an array for the CGPoint of your MKPolyline or not?Hesson
I do have a CGPoint Array being populated with the MapPoints. convertMapPointsToCGPoints(_:) I think everything there is correct. So it may just be the scaling?Felishafelita
I'm pretty sure it is. You should get the width and the height of the view in which you have your map points and then with proportionality look at how 1pt in that space equals to 1pt in you new spaceHesson
yes! took me a while, not sure why but also I was using polylineView.center rather than polylineView.bounds.center which was also a factor in alignment. Not sure entirely why yet, but now works as desired!Felishafelita

© 2022 - 2024 — McMap. All rights reserved.