iosswiftcashapelayermacawswiftsvg

How to hit multiple CAShapeLayer in swift?


I have got mutliple bezier paths which are incorporated into CAShapeLayers and then add all layers to UIImageView. I have implemented hittest to all layers for selection, But it select the last CAShapeLayer. I want to select others layer as touch, but i don't know how?

here is my code for touch.

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
{
    super.touchesBegan(touches, with: event)
            if let touch = touches.first, let touchedLayer = self.layerFor(touch)
            {
                print("hi")
                selectedLayer = touchedLayer
                touchedLayer.strokeColor = UIColor.red.cgColor
                touchedLayer.lineWidth = CGFloat(3)
            }
    
    
}

    private func layerFor(_ touch: UITouch) -> CAShapeLayer?
{
    let touchLocation = touch.location(in: self.backgroundIV)
    let locationInView = self.backgroundIV!.convert(touchLocation, to: nil)
    print("\(locationInView.x)  \(locationInView.y)")
    let hitPresentationLayer = view!.layer.presentation()?.hitTest(locationInView) as? CAShapeLayer
    return hitPresentationLayer?.model()
}

Here is how I create layers from path

    fileprivate func createLayer(path: SVGBezierPath) -> CAShapeLayer {
    let shapeLayer = CAShapeLayer()
    shapeLayer.path = path.cgPath
    if let any = path.svgAttributes["stroke"] {
        shapeLayer.strokeColor = (any as! CGColor)
    }
    
    if let any = path.svgAttributes["fill"] {
        shapeLayer.fillColor = (any as! CGColor)
    }
    return shapeLayer
}

EDIT: here is the code that add shape layers to parent view

        if let svgURL = Bundle.main.url(forResource: "image", withExtension: "svg") {
        let paths = SVGBezierPath.pathsFromSVG(at: svgURL)
        let scale = CGFloat(0.5)
        for path in paths {
            path.apply(CGAffineTransform(scaleX: scale, y: scale))
            items.append(path)
            let layer = createLayer(path: path)
            layer.frame = self.backgroundIV.bounds
            self.backgroundIV.layer.addSublayer(layer)
        }


    }

and changes in touchBegan methods

 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
{

    let point = touches.first?.location(in: self.backgroundIV)
    if let layer = self.backgroundIV.layer.hitTest(point!) as? CAShapeLayer {
        selectedLayer = layer
    selectedLayer.strokeColor = UIColor.red.cgColor
    selectedLayer.lineWidth = CGFloat(3)
        print("Touched")
    }
}

Solution

  • I'll make a couple assumptions here...

    1. you're using PocketSVG (or similar)
    2. you want to detect a tap inside the shape off the layer

    Even if you're only looking for the layer (not only inside the path of the layer), I would recommend looping through the sublayers and using .contains(point) rather than trying to use layer.hitTest(point).

    Here is a quick example:

    import UIKit
    import PocketSVG
    
    enum DetectMode {
        case All, TopMost, BottomMost
    }
    enum DetectType {
        case ShapePath, ShapeBounds
    }
    
    class BoxesViewController: UIViewController {
        
        var backgroundIV: UIImageView!
        
        var detectMode: DetectMode = .All
        var detectType: DetectType = .ShapePath
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemYellow
            
            guard let svgURL = Bundle.main.url(forResource: "roundedboxes", withExtension: "svg") else {
                fatalError("SVG file not found!!!")
            }
            
            backgroundIV = UIImageView()
            
            let paths = SVGBezierPath.pathsFromSVG(at: svgURL)
            let scale = CGFloat(0.5)
            for path in paths {
                path.apply(CGAffineTransform(scaleX: scale, y: scale))
                //items.append(path)
                let layer = createLayer(path: path)
                self.backgroundIV.layer.addSublayer(layer)
            }
    
            let modeControl = UISegmentedControl(items: ["All", "Top Most", "Bottom Most"])
            let typeControl = UISegmentedControl(items: ["Shape Path", "Shape Bounding Box"])
    
            modeControl.translatesAutoresizingMaskIntoConstraints = false
            typeControl.translatesAutoresizingMaskIntoConstraints = false
            backgroundIV.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(modeControl)
            view.addSubview(typeControl)
            view.addSubview(backgroundIV)
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
    
                modeControl.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                modeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                modeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
    
                typeControl.topAnchor.constraint(equalTo: modeControl.bottomAnchor, constant: 40.0),
                typeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                typeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                
                backgroundIV.topAnchor.constraint(equalTo: typeControl.bottomAnchor, constant: 40.0),
                backgroundIV.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                backgroundIV.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                backgroundIV.heightAnchor.constraint(equalTo: backgroundIV.widthAnchor),
    
            ])
            
            modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
            typeControl.addTarget(self, action: #selector(typeChanged(_:)), for: .valueChanged)
            
            modeControl.selectedSegmentIndex = 0
            typeControl.selectedSegmentIndex = 0
            
            // so we can see the frame of the image view
            backgroundIV.backgroundColor = .white
    
        }
        
        @objc func modeChanged(_ sender: UISegmentedControl) -> Void {
            switch sender.selectedSegmentIndex {
            case 0:
                detectMode = .All
            case 1:
                detectMode = .TopMost
            case 2:
                detectMode = .BottomMost
            default:
                ()
            }
        }
        
        @objc func typeChanged(_ sender: UISegmentedControl) -> Void {
            switch sender.selectedSegmentIndex {
            case 0:
                detectType = .ShapePath
            case 1:
                detectType = .ShapeBounds
            default:
                ()
            }
        }
        
        fileprivate func createLayer(path: SVGBezierPath) -> CAShapeLayer {
            let shapeLayer = CAShapeLayer()
            shapeLayer.path = path.cgPath
            if let any = path.svgAttributes["stroke"] {
                shapeLayer.strokeColor = (any as! CGColor)
            }
            
            if let any = path.svgAttributes["fill"] {
                shapeLayer.fillColor = (any as! CGColor)
            }
            return shapeLayer
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            
            guard let point = touches.first?.location(in: self.backgroundIV),
                  // make sure backgroundIV has sublayers
                  let layers = self.backgroundIV.layer.sublayers
            else { return }
            
            var hitLayers: [CAShapeLayer] = []
            
            // loop through all sublayers
            for subLayer in layers {
                // make sure
                //  it is a CAShapeLayer
                //  it has a path
                if let thisLayer = subLayer as? CAShapeLayer,
                   let pth = thisLayer.path {
                    // clear the lineWidth... we'll reset it after getting the hit layers
                    thisLayer.lineWidth = 0
                    
                    // convert touch point from backgroundIV.layer to thisLayer
                    let layerPoint: CGPoint = thisLayer.convert(point, from: self.backgroundIV.layer)
                    
                    if detectType == .ShapePath {
                    // does the path contain the point?
                        if pth.contains(layerPoint) {
                            hitLayers.append(thisLayer)
                        }
                    } else if detectType == .ShapeBounds {
                        if pth.boundingBox.contains(layerPoint) {
                            hitLayers.append(thisLayer)
                        }
                    }
                }
            }
    
            if detectMode == .All {
                hitLayers.forEach { layer in
                    layer.strokeColor = UIColor.cyan.cgColor
                    layer.lineWidth = 3
                }
            } else if detectMode == .TopMost {
                if let layer = hitLayers.last {
                    layer.strokeColor = UIColor.cyan.cgColor
                    layer.lineWidth = 3
                }
            } else if detectMode == .BottomMost {
                if let layer = hitLayers.first {
                    layer.strokeColor = UIColor.cyan.cgColor
                    layer.lineWidth = 3
                }
            }
    
        }
        
    }
    

    When you run this, it will look like this (I'm in a navigation controller):

    enter image description here

    This is the SVG file I used:

    roundedboxes.svg

    <?xml version="1.0" encoding="UTF-8"?>
    <svg width="240px" height="240px" viewBox="0 0 240 240" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
        <title>Untitled</title>
        <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
            <rect id="RedRectangle" fill-opacity="0.75" fill="#FF0000" x="0" y="0" width="160" height="160" rx="60"></rect>
            <rect id="GreenRectangle" fill-opacity="0.75" fill="#00FF00" x="80" y="0" width="160" height="160" rx="60"></rect>
            <rect id="BlueRectangle" fill-opacity="0.75" fill="#0000FF" x="40" y="80" width="160" height="160" rx="60"></rect>
        </g>
    </svg>
    

    By default, we're going to test for tap inside the shape path on each layer, and we'll highlight all layers that pass the test:

    enter image description here

    Note that tapping where the shapes / layers overlap will select all layers where the tap is inside its path.

    If we want only the Top Most layer, we'll get this:

    enter image description here

    If we want only the Bottom Most layer, we'll get this:

    enter image description here

    If we switch to detecting the Shape Bounding Box, we get this:

    enter image description here

    If that is at least close to what you're trying for, play around with this example code and see what you get.


    Edit - minor change...

    Using SVGImageView instead of UIImageView so we can scale the SVG to fit the view:

    class BoxesViewController: UIViewController {
        
        var backgroundIV: SVGImageView!
        
        var detectMode: DetectMode = .All
        var detectType: DetectType = .ShapePath
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemYellow
            
            let svgName = "roundedboxes"
            guard let svgURL = Bundle.main.url(forResource: svgName, withExtension: "svg") else {
                fatalError("SVG file not found!!!")
            }
            
            backgroundIV = SVGImageView.init(contentsOf: svgURL)
            
            backgroundIV.contentMode = .scaleAspectFit
            
            let modeControl = UISegmentedControl(items: ["All", "Top Most", "Bottom Most"])
            let typeControl = UISegmentedControl(items: ["Shape Path", "Shape Bounding Box"])
            
            modeControl.translatesAutoresizingMaskIntoConstraints = false
            typeControl.translatesAutoresizingMaskIntoConstraints = false
            backgroundIV.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(modeControl)
            view.addSubview(typeControl)
            view.addSubview(backgroundIV)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                modeControl.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                modeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                modeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                
                typeControl.topAnchor.constraint(equalTo: modeControl.bottomAnchor, constant: 40.0),
                typeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                typeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                
                backgroundIV.topAnchor.constraint(equalTo: typeControl.bottomAnchor, constant: 40.0),
                backgroundIV.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                backgroundIV.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                backgroundIV.heightAnchor.constraint(equalTo: backgroundIV.widthAnchor),
                
            ])
            
            modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
            typeControl.addTarget(self, action: #selector(typeChanged(_:)), for: .valueChanged)
            
            modeControl.selectedSegmentIndex = 0
            typeControl.selectedSegmentIndex = 0
            
            // so we can see the frame of the image view
            backgroundIV.backgroundColor = .white
            
        }
        
        @objc func modeChanged(_ sender: UISegmentedControl) -> Void {
            switch sender.selectedSegmentIndex {
            case 0:
                detectMode = .All
            case 1:
                detectMode = .TopMost
            case 2:
                detectMode = .BottomMost
            default:
                ()
            }
        }
        
        @objc func typeChanged(_ sender: UISegmentedControl) -> Void {
            switch sender.selectedSegmentIndex {
            case 0:
                detectType = .ShapePath
            case 1:
                detectType = .ShapeBounds
            default:
                ()
            }
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            
            guard let point = touches.first?.location(in: self.backgroundIV),
                  // make sure backgroundIV has sublayers
                  let layers = self.backgroundIV.layer.sublayers
            else { return }
            
            var hitLayers: [CAShapeLayer] = []
            
            // loop through all sublayers
            for subLayer in layers {
                // make sure
                //  it is a CAShapeLayer
                //  it has a path
                if let thisLayer = subLayer as? CAShapeLayer,
                   let pth = thisLayer.path {
                    // clear the lineWidth... we'll reset it after getting the hit layers
                    thisLayer.lineWidth = 0
                    
                    // convert touch point from backgroundIV.layer to thisLayer
                    let layerPoint: CGPoint = thisLayer.convert(point, from: self.backgroundIV.layer)
                    
                    if detectType == .ShapePath {
                        // does the path contain the point?
                        if pth.contains(layerPoint) {
                            hitLayers.append(thisLayer)
                        }
                    } else if detectType == .ShapeBounds {
                        if pth.boundingBox.contains(layerPoint) {
                            hitLayers.append(thisLayer)
                        }
                    }
                }
            }
            
            if detectMode == .All {
                hitLayers.forEach { layer in
                    layer.strokeColor = UIColor.cyan.cgColor
                    layer.lineWidth = 3
                }
            } else if detectMode == .TopMost {
                if let layer = hitLayers.last {
                    layer.strokeColor = UIColor.cyan.cgColor
                    layer.lineWidth = 3
                }
            } else if detectMode == .BottomMost {
                if let layer = hitLayers.first {
                    layer.strokeColor = UIColor.cyan.cgColor
                    layer.lineWidth = 3
                }
            }
            
        }
        
    }
    

    Result:

    enter image description here