Search code examples
iosswiftcashapelayer

Assigning reference to Shape in swift


Just started with Swift (and OOO in general about a week ago so bear with me).

Following this "tutorial" (https://www.youtube.com/watch?v=bwDJspl5sbg), I was able to get the following code which draws a grid of circles.

 //get the bounds of the view we're in
    let viewWidth = self.view.bounds.width
    let viewHeight = self.view.bounds.height

    //set parameters for gride of circles
    let margin: CGFloat = 25
    let xMargin = margin
    let yMargin = margin
    let circleBreathingSpace: CGFloat = 5
    let circleLineWidth: CGFloat = 4

    let across: CGFloat = 6
    let gridRowWidth: CGFloat = viewWidth - (2.0 * margin)
    let cellWidth: CGFloat = gridRowWidth / across
    let cellHeight: CGFloat = cellWidth
    let down: CGFloat = (viewHeight - (2 * yMargin)) / cellHeight

    let circleRadius: CGFloat = (cellWidth - (2 * circleBreathingSpace)) / 2


    //draw the grid
    for xx in 0..<Int(across){
        for yy in 0..<Int(down){
            let circleX: CGFloat = xMargin + ((CGFloat(xx) + 0.5) * cellWidth)
            let circleY: CGFloat = yMargin + ((CGFloat(yy) + 0.5) * cellHeight)

            //Draw Circle [ref: http://stackoverflow.com/questions/29616992/how-do-i-draw-a-circle-in-ios-swift ]
            let circlePath = UIBezierPath(arcCenter: CGPoint(x: circleX,y: circleY),
                                          radius: circleRadius, startAngle: CGFloat(0),
                                          endAngle:CGFloat(M_PI * 2),
                                          clockwise: true)


            let shapeLayer = CAShapeLayer()
            shapeLayer.path = circlePath.cgPath

            //change the fill color
            shapeLayer.fillColor = UIColor.blue.cgColor
            //you can change the stroke color
            shapeLayer.strokeColor = UIColor.black.cgColor
            //you can change the line width
            shapeLayer.lineWidth = 6.0

            view.layer.addSublayer(shapeLayer)




        }
    }

Question Now what I'm wondering is how I can give a "reference" to each of these individual circles to manipulate their properties later on.

Context
I basically want to have a grid of these circles slowly disappear according to an input of time. So if I have 20 minutes as an input, I'll get 20 circles and each will take 60 seconds to disappear. That's just context for what I'm doing, not necessarily the question itself (although any direction in that area wouldn't hurt either :) )

My Thoughts My idea was maybe to create an array and add the circle currently being created to this array. Then I could loop through the array after and slowly decrease the size of each circle making them shrink and disappear one by one. Even if that is a feasible solution, I'm not quite sure how to go about it.

Thanks in advance!


Solution

  • Yes, you could create an array for those layers:

    var shapeLayers = [CAShapeLayer]()
    

    Then, as you create shape layers, you can add them to that array.


    Alternatively, you might want to use an array of arrays (in effect, a two dimensional array) so that it more accurately mirrors the visual representation. You could do something like above, creating an array and adding shapes to the array for each row, and then create another array to hold those individual arrays for the rows of shape layers. But maybe more elegant, you can use map calls to build the array of array of CAShapeLayer objects.

    The basic idea is that you take a range of integers to build an array. Let's consider a simple example where I want a simple array of 10 Int objects, 0, 2, 4, ... 18:

    let array = (0 ..< 10).map { (i) -> Int in
        return i * 2
    }
    

    But this works for building an array of CAShapeLayer objects, too:

    let shapeArray = (0 ..< 10).map { (xx) -> CAShapeLayer in
        let shapeLayer = CAShapeLayer()
        // ... configure that shapeLayer here
        return shapeLayer
    }
    

    So that builds a "row" of CAShapeLayer objects, as xx progresses from 0 to 10.

    You can then nest that inside an map for all of the rows, too. So the inner map returns an array of CAShapeLayer objects, but the outer map returns an array of those arrays, and in ending up with a [[CAShapeLayer]]:

    let arrayOfArrays = (0 ..< 20).map { (yy) -> [CAShapeLayer] in
        return (0 ..< 10).map { (xx) -> CAShapeLayer in
            let shapeLayer = CAShapeLayer()
            // ... configure that shapeLayer here
            return shapeLayer
        }
    }
    

    You can then iterate through those like so:

    for shapeLayers in arrayOfArrays {
        for shapeLayer in shapeLayers {
            // do something with `shapeLayer`, e.g.
    
            view.layer.addSublayer(shapeLayer)
        }
    }
    

    So, going back to your example:

    let viewWidth = view.bounds.width
    let viewHeight = view.bounds.height
    
    //set parameters for gride of circles
    let margin: CGFloat = 25
    let xMargin = margin
    let yMargin = margin
    let circleBreathingSpace: CGFloat = 5
    let circleLineWidth: CGFloat = 6
    
    let across: CGFloat = 6
    let gridRowWidth: CGFloat = viewWidth - (2.0 * margin)
    let cellWidth: CGFloat = gridRowWidth / across
    let cellHeight: CGFloat = cellWidth
    let down: CGFloat = (viewHeight - (2 * yMargin)) / cellHeight
    
    let circleRadius: CGFloat = (cellWidth - (2 * circleBreathingSpace)) / 2
    
    //build the grid
    let circles = (0 ..< Int(down)).map { (yy) -> [CAShapeLayer] in
        return (0 ..< Int(across)).map { (xx) -> CAShapeLayer in
            let circleX: CGFloat = xMargin + ((CGFloat(xx) + 0.5) * cellWidth)
            let circleY: CGFloat = yMargin + ((CGFloat(yy) + 0.5) * cellHeight)
    
            let path = UIBezierPath(arcCenter: CGPoint(x: circleX, y: circleY),
                                          radius: circleRadius, startAngle: 0,
                                          endAngle: .pi * 2,
                                          clockwise: true)
    
            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
    
            shapeLayer.fillColor = UIColor.blue.cgColor
            shapeLayer.strokeColor = UIColor.black.cgColor
            shapeLayer.lineWidth = circleLineWidth
    
            return shapeLayer
        }
    }
    
    for row in circles {
        for shapeLayer in row {
            view.layer.addSublayer(shapeLayer)
        }
    }
    

    By the way, if you want them to disappear over time, you might consider a repeating timer. And I'd animate them going to clear color:

    var row = 0
    var col = 0
    Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
        let shapeLayer = circles[col][row]
    
        let animation = CABasicAnimation(keyPath: "opacity")
        animation.fromValue = 1
        animation.toValue = 0
        animation.duration = 2
        shapeLayer.opacity = 0
    
        shapeLayer.add(animation, forKey: nil)
    
        row += 1
        if row >= circles[0].count {
            row = 0
            col += 1
            if col >= circles.count {
                timer.invalidate()
            }
        }
    }
    

    That yields:

    fade

    Note, it might be tempting to use the nested for loop pattern (like we did when adding the shape layers to the layer hierarchy), creating a separate timer for each circle to be removed, but that introduces two issues:

    First, if you have to cancel all of these, you don't really want to have to iterate through arrays of timers in order to cancel them. It's nicer to be able to invalidate a single repeating timer. Second, the firing of multiple timers scheduled in the future can be thrown off by "timer coalescing" (where, in an effort to save power, the OS will start firing off distant timers in groups). Yes, you can use GCD timers and turn off timer coalescing, but repeating timer eliminates the issue altogether.


    I notice that you wanted the circles to shrink, rather than fade, like I did above. So you could do the following:

    let viewWidth = view.bounds.width
    let viewHeight = view.bounds.height
    
    //set parameters for gride of circles
    let margin: CGFloat = 25
    let xMargin = margin
    let yMargin = margin
    let circleBreathingSpace: CGFloat = 5
    let circleLineWidth: CGFloat = 6
    
    let across: CGFloat = 6
    let gridRowWidth: CGFloat = viewWidth - (2.0 * margin)
    let cellWidth: CGFloat = gridRowWidth / across
    let cellHeight: CGFloat = cellWidth
    let down: CGFloat = (viewHeight - (2 * yMargin)) / cellHeight
    
    let circleRadius: CGFloat = (cellWidth - (2 * circleBreathingSpace)) / 2
    
    //build the grid
    let circles = (0 ..< Int(down)).map { (yy) -> [CAShapeLayer] in
        (0 ..< Int(across)).map { (xx) -> CAShapeLayer in
            let circleX: CGFloat = xMargin + ((CGFloat(xx) + 0.5) * cellWidth)
            let circleY: CGFloat = yMargin + ((CGFloat(yy) + 0.5) * cellHeight)
    
            let path = UIBezierPath(arcCenter: .zero,
                                    radius: circleRadius, startAngle: 0,
                                    endAngle: .pi * 2,
                                    clockwise: true)
    
            let shapeLayer = CAShapeLayer()
            shapeLayer.position = CGPoint(x: circleX, y: circleY)
            shapeLayer.path = path.cgPath
    
            shapeLayer.fillColor = UIColor.blue.cgColor
            shapeLayer.strokeColor = UIColor.black.cgColor
            shapeLayer.lineWidth = circleLineWidth
    
            return shapeLayer
        }
    }
    
    //add grid to view's layer
    for row in circles {
        for shapeLayer in row {
            view.layer.addSublayer(shapeLayer)
        }
    }
    

    Note, I tweaked that so that the path was about .zero, but moved the position of the layer to where I wanted the circle. That allows me to simply adjust the scale of the transform of the layer when I want them to disappear:

    var row = 0
    var col = 0
    
    Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
        let shapeLayer = circles[col][row]
    
        let transformAnimation = CABasicAnimation(keyPath: "transform")
    
        let transform = CATransform3DMakeScale(0, 0, 1)
        transformAnimation.fromValue = shapeLayer.transform
        transformAnimation.toValue = transform
        transformAnimation.duration = 2
        shapeLayer.transform = transform
    
        shapeLayer.add(transformAnimation, forKey: nil)
    
        row += 1
        if row >= circles[0].count {
            row = 0
            col += 1
            if col >= circles.count {
                timer.invalidate()
            }
        }
    }
    

    That yields:

    shrinking