iosswiftiphonegraphicsnsbezierpath

How to achieve "application guide tutorial screen" with the focus on a particular view swift


I want to achieve the following focused UI. It is basically the guideline tutorial view for my users.

enter image description here

The background is my main view controller with a bunch of real functional views and I put a view with 50% transparency on black on top of it to achieve the focused UI but it's far away from what I am trying to achieve. Any suggestion would be highly appreciated.

Code:

func createOverlay(frame: CGRect,
                   xOffset: CGFloat,
                   yOffset: CGFloat,
                   radius: CGFloat) -> UIView {
    // Step 1
    let overlayView = UIView(frame: frame)
    overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
    // Step 2
    let path = CGMutablePath()
    path.addArc(center: CGPoint(x: xOffset, y: yOffset),
                radius: radius,
                startAngle: 0.0,
                endAngle: 2.0 * .pi,
                clockwise: false)
    path.addRect(CGRect(origin: .zero, size: overlayView.frame.size))
    // Step 3
    let maskLayer = CAShapeLayer()
    maskLayer.backgroundColor = UIColor.black.cgColor
    maskLayer.path = path
    // For Swift 4.0
    maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
    // For Swift 4.2
    maskLayer.fillRule = .evenOdd
    // Step 4
    overlayView.layer.mask = maskLayer
    overlayView.clipsToBounds = true

    return overlayView
}

Current output:
enter image description here


Solution

  • We can do this by using an "inverted" shadow-path on the overlay view's layer. That will give us a "feathered-edge" oval.

    Here is an example view class:

    class FocusView : UIView {
        
        // this will be the frame of the "see-through" oval
        public var ovalRect: CGRect = .zero {
            didSet { setNeedsLayout() }
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
    
            // create an oval inside ovalRect
            let clearPath = UIBezierPath(ovalIn: ovalRect)
            
            // create a rectangle path larger than entire view
            //  so we don't see "feathering" on outer edges
            let opaquePath = UIBezierPath(rect: bounds.insetBy(dx: -80.0, dy: -80.0)).reversing()
            
            // append the paths so we get a "see-through" oval
            clearPath.append(opaquePath)
            
            self.layer.shadowPath = clearPath.cgPath
            self.layer.shadowColor = UIColor.black.cgColor
            self.layer.shadowOffset = CGSize.zero
            
            // adjust the opacity as desired
            self.layer.shadowOpacity = 0.5
            
            // adjust shadow radius as desired (controls the "feathered" edge)
            self.layer.shadowRadius = 8
        }
        
    }
    

    and an example controller. We'll add 6 colored rectangles to use as a "background" and a UILabel to "focus" on:

    class ViewController: UIViewController {
        
        let focusView = FocusView()
    
        let label = UILabel()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .darkGray
            
            // let's add six different color rectangles as the "background"
            let colors: [UIColor] = [
                .systemRed, .systemGreen, .systemBlue,
                .systemBrown, .systemYellow, .systemCyan,
            ]
            let vStack = UIStackView()
            vStack.axis = .vertical
            vStack.distribution = .fillEqually
            var i: Int = 0
            for _ in 0..<3 {
                let hStack = UIStackView()
                hStack.distribution = .fillEqually
                for _ in 0..<2 {
                    let v = UIView()
                    v.backgroundColor = colors[i % colors.count]
                    hStack.addArrangedSubview(v)
                    i += 1
                }
                vStack.addArrangedSubview(hStack)
            }
    
            vStack.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(vStack)
            
            label.font = .systemFont(ofSize: 30.0, weight: .bold)
            label.textColor = .white
            label.textAlignment = .center
            label.text = "Example"
            
            label.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(label)
            
            focusView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(focusView)
            
            // start with it hidden and transparent
            focusView.isHidden = true
            focusView.alpha = 0.0
            
            NSLayoutConstraint.activate([
                
                vStack.topAnchor.constraint(equalTo: view.topAnchor),
                vStack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                vStack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                vStack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
    
                label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 80.0),
                
                focusView.topAnchor.constraint(equalTo: view.topAnchor),
                focusView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                focusView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                focusView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                
            ])
    
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            
            // we may want to make sure the focusView is on top of all other views
            view.bringSubviewToFront(focusView)
            
            // set the focusView's "see-through" oval rect
            // it can be set with a hard-coded rect, or
            //  for this example, we'll use the label frame
            //  expanded by 80-points horizontally, 60-points vertically
            focusView.ovalRect = label.frame.insetBy(dx: -40.0, dy: -30.0)
            
            if focusView.isHidden {
                focusView.isHidden = false
                UIView.animate(withDuration: 0.3, animations: {
                    self.focusView.alpha = 1.0
                })
            } else {
                UIView.animate(withDuration: 0.3, animations: {
                    self.focusView.alpha = 0.0
                }, completion: { _ in
                    self.focusView.isHidden = true
                })
            }
        }
    }
    

    Each tap anywhere will either fade-in or fade-out the focus view:

    enter image description here

    enter image description here