skip to Main Content

I have created a TicketView using Bezier Paths, which looks like this:

Ticket View

Now I am trying to add a shadow to it, but I encountered a problem. I also have an image on the left side of this view, and the rounded corners of this ticket view is supposed to work as a mask to it. However, when I apply the mask, the shadow does not work as expected. So far, the only workaround I have found is to add a container view behind the TicketView and apply the shadow to it. Unfortunately, this approach does not make the shadow follow the custom path I created for the TicketView.

Is there any way to apply a shadow that will correctly follow the custom path I created, while still keeping the mask applied?

Here is the code for reference:

import UIKit

/// A card view with rounded corners on the left side and a zig-zag shape on the right side
class TicketView: UIView {
    /// The radius used for the left side of this view
    let radius: CGFloat
    /// The zig-zag's width
    let zigZagsWidth: CGFloat
    /// The position within the card's view where the zig-zag starts (1.0 = the end of the view)
    let zigZagStartPosition: CGFloat

    public init(
        radius: CGFloat = 10,
        zigZagsWidth: CGFloat = 5,
        zigZagStartPosition: CGFloat = 1.0,
        frame: CGRect = .zero
    ) {
        self.radius = radius
        self.zigZagsWidth = zigZagsWidth
        self.zigZagStartPosition = zigZagStartPosition
        super.init(frame: frame)
        self.backgroundColor = .clear
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.5
        layer.shadowRadius = 5.0
        layer.shadowOffset = CGSize(width: 0, height: 0)
        layer.shadowPath = (layer.mask as? CAShapeLayer)?.path
    }

    public override func draw(_ rect: CGRect) {
        super.draw(rect)

        guard let context = UIGraphicsGetCurrentContext() else {
            return
        }

        let path = UIBezierPath()

        // Top-Left
        let topLeftCorner = CGPoint(x: bounds.minX, y: bounds.minY)
        let bottomLeftCorner = CGPoint(x: bounds.minX, y: bounds.maxY)

        path.move(to: CGPoint(x: topLeftCorner.x, y: topLeftCorner.y))
        path.addArc(withCenter: CGPoint(x: topLeftCorner.x + radius, y: topLeftCorner.y + radius),
                    radius: radius,
                    startAngle: .pi,
                    endAngle: .pi * 1.5,
                    clockwise: true)

        // Zig-Zag
        let zigzagHeight: CGFloat = rect.size.height / (rect.size.height / 7.3)
        let zigzagWidth: CGFloat = zigZagsWidth
        let numberOfSegments = Int(rect.size.height / zigzagHeight) + 1

        for index in 0..<numberOfSegments {
            let inY = CGFloat(index) * zigzagHeight
            let inX = (index % 2 == 0) ? rect.size.width * zigZagStartPosition :
            rect.size.width * zigZagStartPosition - zigzagWidth
            path.addLine(to: CGPoint(x: inX, y: inY))

            let zigZagCurveRadius: CGFloat = 2
            let zigZagCurveCenter = CGPoint(
                x: inX + zigZagCurveRadius,
                y: inY + zigZagCurveRadius
            )
            path.addArc(withCenter: zigZagCurveCenter,
                        radius: zigZagCurveRadius,
                        startAngle: .pi,
                        endAngle: .pi,
                        clockwise: true)
        }

        // Bottom-Left
        path.addLine(to: CGPoint(x: bottomLeftCorner.x + radius, y: bottomLeftCorner.y))
        path.addArc(withCenter: CGPoint(x: bottomLeftCorner.x + radius, y: bottomLeftCorner.y - radius),
                    radius: radius,
                    startAngle: .pi * 1.5,
                    endAngle: .pi,
                    clockwise: true)

        path.close()

        context.setFillColor(UIColor.white.cgColor)
        path.fill()

        // Mask
        let mask = CAShapeLayer()
        mask.path = path.cgPath
        layer.mask = mask
    }
}

2

Answers


  1. The problem is that the layer you are masking is the same layer that you apply the shadow to. This causes the shadow to be masked, i.e. not visible.

    Adding a container view works and applying the shadow to the container view works, because now the mask is applied to the view in the container, and the shadow is on the container, so the shadow is not masked away.

    You can also add a subview inside TicketView – the shadow is applied to TicketView, but the mask is applied to the subview.

    Or, just add a sublayer. The shadow is applied to TicketView.layer, but the mask is applied to the sublayer.

    I’m not sure how you observed "this approach does not make the shadow follow the custom path I created for the TicketView". If you have set shadowPath to the correct path, then it would work. That said, the actual path of the shadows on zig zags like this can be hard to see. To see that this does work, exaggerate the zigzag width, and reduce the shadow radius. You should see that the shadow path follows the zigzag.

    enter image description here

    I’ve used the "apply shadow to TicketView, and apply mask to a subview of TicketView" approach to create that. Full code (things I’ve changed are addressed in the comments):

    class TicketView: UIView {
        let radius: CGFloat
        let zigZagsWidth: CGFloat
        let zigZagStartPosition: CGFloat
        
        // the subview that we are applying a mask to
        let maskedSubview: UIView
    
        public init(
            radius: CGFloat = 10,
            zigZagsWidth: CGFloat = 50, // exaggerated this
            zigZagStartPosition: CGFloat = 1.0,
            frame: CGRect = .zero
        ) {
            self.radius = radius
            self.zigZagsWidth = zigZagsWidth
            self.zigZagStartPosition = zigZagStartPosition
            
            // create the subview in init
            maskedSubview = UIView()
            maskedSubview.backgroundColor = .white
            
            super.init(frame: frame)
            
            addSubview(maskedSubview)
            maskedSubview.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                maskedSubview.topAnchor.constraint(equalTo: topAnchor),
                maskedSubview.bottomAnchor.constraint(equalTo: bottomAnchor),
                maskedSubview.leadingAnchor.constraint(equalTo: leadingAnchor),
                maskedSubview.trailingAnchor.constraint(equalTo: trailingAnchor),
            ])
            
            // set shadow properties except shadowPath here
            layer.shadowColor = UIColor.black.cgColor
            layer.shadowOpacity = 0.5
            layer.shadowRadius = 1 // made this smaller to see more clearly
            layer.shadowOffset = CGSize(width: 0, height: 0)
            self.backgroundColor = .clear
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
            
            // layoutSubviews is called, so the view bounds might have changed
            // recompute the mask path
            let mask = CAShapeLayer()
            mask.path = makeTicketPath().cgPath
    
            // apply mask to maskedSubview.layer
            maskedSubview.layer.mask = mask
    
            // apply shadow to self.layer
            layer.shadowPath = mask.path
        }
        
        // I've extracted the path-drawing function:
        private func makeTicketPath() -> UIBezierPath {
            let path = UIBezierPath()
            let rect = bounds
            let topLeftCorner = CGPoint(x: bounds.minX, y: bounds.minY)
            let bottomLeftCorner = CGPoint(x: bounds.minX, y: bounds.maxY)
    
            path.move(to: CGPoint(x: topLeftCorner.x, y: topLeftCorner.y))
            path.addArc(withCenter: CGPoint(x: topLeftCorner.x + radius, y: topLeftCorner.y + radius),
                        radius: radius,
                        startAngle: .pi,
                        endAngle: .pi * 1.5,
                        clockwise: true)
    
            let zigzagHeight: CGFloat = rect.size.height / (rect.size.height / 7.3)
            let zigzagWidth: CGFloat = zigZagsWidth
            let numberOfSegments = Int(rect.size.height / zigzagHeight) + 1
    
            for index in 0..<numberOfSegments {
                let inY = CGFloat(index) * zigzagHeight
                let inX = (index % 2 == 0) ? rect.size.width * zigZagStartPosition :
                rect.size.width * zigZagStartPosition - zigzagWidth
                path.addLine(to: CGPoint(x: inX, y: inY))
    
                let zigZagCurveRadius: CGFloat = 2
                let zigZagCurveCenter = CGPoint(
                    x: inX + zigZagCurveRadius,
                    y: inY + zigZagCurveRadius
                )
                path.addArc(withCenter: zigZagCurveCenter,
                            radius: zigZagCurveRadius,
                            startAngle: .pi,
                            endAngle: .pi,
                            clockwise: true)
            }
    
            path.addLine(to: CGPoint(x: bottomLeftCorner.x + radius, y: bottomLeftCorner.y))
            path.addArc(withCenter: CGPoint(x: bottomLeftCorner.x + radius, y: bottomLeftCorner.y - radius),
                        radius: radius,
                        startAngle: .pi * 1.5,
                        endAngle: .pi,
                        clockwise: true)
    
            path.close()
            return path
        }
    }
    
    Login or Signup to reply.
  2. As Sweeper said (+1), the problem is the mask on the layer.

    Personally, I would simplify this and just set the base layer of the view to be a CAShapeLayer, then you can set its path (and other properties) and then set the shadowPath, and be done with it:

    class TicketView: UIView {
        override class var layerClass: AnyClass { CAShapeLayer.self }
        var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
    
        …
    
        convenience init(radius: CGFloat = 10, zigZagsWidth: CGFloat = 5, zigZagStartPosition: CGFloat = 1) {
            self.init()
    
            self.radius = radius
            self.zigZagsWidth = zigZagsWidth
            self.zigZagStartPosition = zigZagStartPosition
    
            backgroundColor = .clear
            shapeLayer.fillColor = .white
            layer.shadowRadius = 2
            layer.shadowOpacity = 1
            layer.shadowOffset = .zero
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let path = customPath().cgPath
    
            shapeLayer.path = path
            layer.shadowPath = path
        }
    }
    

    I would make these properties mutable, but just have them trigger a layout if mutated:

    /// The radius used for the left side of this view
    var radius: CGFloat = 10 { didSet { setNeedsLayout() } }
    
    /// The zig-zag's width
    var zigZagsWidth: CGFloat = 5 { didSet { setNeedsLayout() } }
    
    /// The position within the card's view where the zig-zag starts (1.0 = the end of the view)
    var zigZagStartPosition: CGFloat = 1 { didSet { setNeedsLayout() } }
    

    Yielding:

    enter image description here


    Personally, I always make these @IBDesignable, so I can add them in Interface Builder and customize the parameters in the UI, rather than programmatically, but that is obviously a matter of personal preference. Regardless, here is my full implementation:

    @IBDesignable
    class TicketView: UIView {
        override class var layerClass: AnyClass { CAShapeLayer.self }
        var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
    
        /// The radius used for the left side of this view
        @IBInspectable var radius: CGFloat = 10 { didSet { setNeedsLayout() } }
    
        /// The zig-zag's width
        @IBInspectable var zigZagsWidth: CGFloat = 5 { didSet { setNeedsLayout() } }
    
        /// The position within the card's view where the zig-zag starts (1.0 = the end of the view)
        @IBInspectable var zigZagStartPosition: CGFloat = 1 { didSet { setNeedsLayout() } }
    
        @IBInspectable var fillColor: UIColor? {
            get { shapeLayer.fillColor.flatMap { UIColor(cgColor: $0) } }
            set { shapeLayer.fillColor = newValue?.cgColor }
        }
    
        @IBInspectable var shadowRadius: CGFloat {
            get { layer.shadowRadius }
            set { layer.shadowRadius = newValue }
        }
    
        @IBInspectable var shadowOpacity: Float {
            get { layer.shadowOpacity }
            set { layer.shadowOpacity = newValue }
        }
    
        @IBInspectable var shadowOffset: CGSize {
            get { layer.shadowOffset }
            set { layer.shadowOffset = newValue }
        }
    
        @IBInspectable var shadowColor: UIColor? {
            get { layer.shadowColor.flatMap { UIColor(cgColor: $0) } }
            set { layer.shadowColor = newValue?.cgColor }
        }
    
        convenience init(radius: CGFloat, zigZagsWidth: CGFloat, zigZagStartPosition: CGFloat) {
            self.init()
            self.radius = radius
            self.zigZagsWidth = zigZagsWidth
            self.zigZagStartPosition = zigZagStartPosition
            backgroundColor = .clear
            fillColor = .white
            layer.shadowRadius = 2
            layer.shadowOpacity = 1
            layer.shadowOffset = .zero
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let path = customPath().cgPath
    
            shapeLayer.path = path
            layer.shadowPath = path
        }
    
        func customPath() -> UIBezierPath {
            let path = UIBezierPath()
    
            // Top-Left
            let topLeftCorner = CGPoint(x: bounds.minX, y: bounds.minY)
            let bottomLeftCorner = CGPoint(x: bounds.minX, y: bounds.maxY)
    
            path.move(to: CGPoint(x: topLeftCorner.x, y: topLeftCorner.y))
            path.addArc(withCenter: CGPoint(x: topLeftCorner.x + radius, y: topLeftCorner.y + radius),
                        radius: radius,
                        startAngle: .pi,
                        endAngle: .pi * 1.5,
                        clockwise: true)
    
            // Zig-Zag
            let zigzagHeight: CGFloat = bounds.height / (bounds.height / 7.3)
            let zigzagWidth: CGFloat = zigZagsWidth
            let numberOfSegments = Int(bounds.height / zigzagHeight) + 1
    
            for index in 0..<numberOfSegments {
                let inY = CGFloat(index) * zigzagHeight
                let inX = (index % 2 == 0) ? bounds.width * zigZagStartPosition : bounds.width * zigZagStartPosition - zigzagWidth
                path.addLine(to: CGPoint(x: inX, y: inY))
    
                let zigZagCurveRadius: CGFloat = 2
                let zigZagCurveCenter = CGPoint(
                    x: inX + zigZagCurveRadius,
                    y: inY + zigZagCurveRadius
                )
                path.addArc(
                    withCenter: zigZagCurveCenter,
                    radius: zigZagCurveRadius,
                    startAngle: .pi,
                    endAngle: .pi,
                    clockwise: true
                )
            }
    
            // Bottom-Left
            path.addLine(to: CGPoint(x: bottomLeftCorner.x + radius, y: bottomLeftCorner.y))
            path.addArc(
                withCenter: CGPoint(x: bottomLeftCorner.x + radius, y: bottomLeftCorner.y - radius),
                radius: radius,
                startAngle: .pi * 1.5,
                endAngle: .pi,
                clockwise: true
            )
    
            path.close()
    
            return path
        }
    }
    

    You may also notice that I replaced all references to the rect parameter supplied to the draw(_:) method with bounds.

    The above eliminates the need to implement draw(_:) at all, but if you do implement that, never use rect for constructing the path. Sometimes, draw(_:) can be called where rect is not the same as the bounds. It may only be redrawing part of the view.

    Bottom line, in custom draw(_:) implementations, use the rect to determine what needs to be drawn, but never how it is drawn.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search