0

I've got a small, reusable UIView widget that can be added to any view anywhere, and may or may not always be in the same place or have the same frame. It looks something like this:

class WidgetView: UIView {
    // some stuff, not very exciting
}

In my widget view, there's a situation where I need to create a popup menu with an overlay underneath it. it looks like this:

class WidgetView: UIView {
    // some stuff, not very exciting

    var overlay: UIView!

    commonInit() {
        guard let keyWindow = UIApplication.shared.keyWindow else { return }
        overlay = UIView(frame: keyWindow.frame)
        overlay.alpha = 0
        keyWindow.addSubview(overlay)

        // Set some constraints here

        someControls = CustomControlsView( ... a smaller controls view ... ) 
        overlay.addSubview(someControls)

        // Set some more constraints here!
    }            

    showOverlay() {
        overlay.alpha = 1
    }

    hideOverlay() {
        overlay.alpha = 0
    }
}

Where this gets complicated, is I'm cutting the shape of the originating WidgetView out of the overlay, so that its controls are still visible underneath. This works fine:

class CutoutView: UIView {

    var holes: [CGRect]?

    convenience init(holes: [CGRect], backgroundColor: UIColor?) {
        self.init()

        self.holes = holes

        self.backgroundColor = backgroundColor ?? UIColor.black.withAlphaComponent(0.5)
        isOpaque = false
    }

    override func draw(_ rect: CGRect) {
        backgroundColor?.setFill()
        UIRectFill(rect)

        guard let rectsArray = holes else {
            return
        }

        for holeRect in rectsArray {
            let holeRectIntersection = rect.intersection(holeRect)
            UIColor.clear.setFill()
            UIRectFill(holeRectIntersection)
        }
    }
}

... except the problem:

Touches aren't forwarded through the cutout hole. So I thought I'd be clever, and use this extension to determine whether the pixels at the touch point are transparent or not, but I can't even get that far, because hitTest() and point(inside, with event) don't respond to touches outside of the WidgetView's frame.

The way I can see it, there are four (potential) ways to solve this, but I can't get any of them working.

  1. Find some magical () way to to make hitTest or point(inside) respond anywhere in the keyWindow, or at least the overlayView's frame

  2. Add a UITapGestureRecognizer to the overlayView and forward the appropriate touches to the originating view controller (this partially works — the tap gesture responds, but I don't know where to go from there)

  3. Use a delegate/protocol implementation to tell the original WidgetView to respond to touches

  4. Add the overlay and its subviews to a different parent view altogether that isn't the keyWindow?


Below the fold, here is a complete executable setup, which relies on a new single view project with storyboard. It relies on SnapKit constraints, for which you can use the following podfile:

podfile

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '10.0'
use_frameworks!

target 'YourTarget' do
    pod 'SnapKit', '~> 4.2.0'
end

ViewController.swift

import UIKit
import SnapKit

class ViewController: UIViewController {

    public var utilityToolbar: UtilityToolbar!

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .darkGray
        setup()

    }

    func setup() {

        let button1 = UtilityToolbar.Button(title: "One", buttonPressed: nil)
        let button2 = UtilityToolbar.Button(title: "Two", buttonPressed: nil)
        let button3 = UtilityToolbar.Button(title: "Three", buttonPressed: nil)
        let button4 = UtilityToolbar.Button(title: "Four", buttonPressed: nil)
        let button5 = UtilityToolbar.Button(title: "Five", buttonPressed: nil)

        let menuItems: [UtilityToolbar.Button] = [button1, button2, button3, button4, button5]
        menuItems.forEach({
            $0.setTitleColor(#colorLiteral(red: 0.1963312924, green: 0.2092989385, blue: 0.2291107476, alpha: 1), for: .normal)
        })

        utilityToolbar = UtilityToolbar(title: "One", menuItems: menuItems)
        utilityToolbar.titleButton.setTitleColor(#colorLiteral(red: 0.1963312924, green: 0.2092989385, blue: 0.2291107476, alpha: 1), for: .normal)
        utilityToolbar.backgroundColor = .white
        utilityToolbar.dropdownContainer.backgroundColor = .white

        view.addSubview(utilityToolbar)

        utilityToolbar.snp.makeConstraints { (make) in
            make.left.right.equalToSuperview()
            make.top.equalToSuperview().offset(250)
            make.height.equalTo(50.0)
        }
    }
}

CutoutView.swift

import UIKit

class CutoutView: UIView {

    var holes: [CGRect]?

    convenience init(holes: [CGRect], backgroundColor: UIColor?) {
        self.init()
        self.holes = holes
        self.backgroundColor = backgroundColor ?? UIColor.black.withAlphaComponent(0.5)
        isOpaque = false
    }

    override func draw(_ rect: CGRect) {
        backgroundColor?.setFill()
        UIRectFill(rect)

        guard let rectsArray = holes else { return }

        for holeRect in rectsArray {
            let holeRectIntersection = rect.intersection(holeRect)
            UIColor.clear.setFill()
            UIRectFill(holeRectIntersection)
        }
    }

}

UtilityToolbar.swift

import Foundation import UIKit import SnapKit

class UtilityToolbar: UIView {

    class Button: UIButton {

        var functionIdentifier: String?
        var buttonPressed: (() -> Void)?

        fileprivate var owner: UtilityToolbar?

        convenience init(title: String, buttonPressed: (() -> Void)?) {
            self.init(type: .custom)
            self.setTitle(title, for: .normal)
            self.functionIdentifier = title.lowercased()
            self.buttonPressed = buttonPressed
        }
    }

    enum MenuState {
        case open
        case closed
    }

    enum TitleStyle {
        case label
        case dropdown
    }

    private(set) public var menuState: MenuState = .closed

    var itemHeight: CGFloat = 50.0
    var spacing: CGFloat = 6.0 { didSet { dropdownStackView.spacing = spacing } }
    var duration: TimeInterval = 0.15
    var dropdownContainer: UIView!
    var titleButton: UIButton = UIButton()

    @IBOutlet weak fileprivate var toolbarStackView: UIStackView!
    private var stackViewBottomConstraint: Constraint!
    private var dropdownStackView: UIStackView!
    private var overlayView: CutoutView!
    private var menuItems: [Button] = []
    private var expandedHeight: CGFloat { get { return CGFloat(menuItems.count - 1) * itemHeight + (spacing * 2) } }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }

    convenience init(title: String, menuItems: [Button]) {
        self.init()
        self.titleButton.setTitle(title, for: .normal)
        self.menuItems = menuItems
        commonInit()
    }

    private func commonInit() {

        self.addSubview(titleButton)
        titleButton.addTarget(self, action: #selector(titleButtonPressed(_:)), for: .touchUpInside)
        titleButton.snp.makeConstraints { $0.edges.equalToSuperview() }

        dropdownContainer = UIView()

        dropdownStackView = UIStackView()
        dropdownStackView.axis = .vertical
        dropdownStackView.distribution = .fillEqually
        dropdownStackView.alignment = .fill
        dropdownStackView.spacing = spacing
        dropdownStackView.alpha = 0
        dropdownStackView.translatesAutoresizingMaskIntoConstraints = true

        menuItems.forEach({
            $0.owner = self
            $0.addTarget(self, action: #selector(menuButtonPressed(_:)), for: .touchUpInside)
        })
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // Block if the view isn't fully ready, or if the containerView has already been added to the window
        guard
            let keyWindow = UIApplication.shared.keyWindow,
            self.globalFrame != .zero,
            dropdownContainer.superview == nil else { return }

        overlayView = CutoutView(frame: keyWindow.frame)
        overlayView.backgroundColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.5)
        overlayView.alpha = 0
        overlayView.holes = [self.globalFrame!]
        keyWindow.addSubview(overlayView)
        keyWindow.addSubview(dropdownContainer)
        dropdownContainer.snp.makeConstraints { (make) in
            make.left.right.equalToSuperview()
            make.top.equalToSuperview().offset((self.globalFrame?.origin.y ?? 0) + self.frame.height)
            make.height.equalTo(0)
        }

        dropdownContainer.addSubview(dropdownStackView)

        dropdownStackView.snp.makeConstraints({ (make) in
            make.left.right.equalToSuperview().inset(spacing).priority(.required)
            make.top.equalToSuperview().priority(.medium)
            stackViewBottomConstraint = make.bottom.equalToSuperview().priority(.medium).constraint
        })
    }

    public func openMenu() {

        titleButton.isSelected = true
        dropdownStackView.addArrangedSubviews(menuItems.filter { $0.titleLabel?.text != titleButton.titleLabel?.text })
        dropdownContainer.layoutIfNeeded()
        dropdownContainer.snp.updateConstraints { (make) in
            make.height.equalTo(self.expandedHeight)
        }

        stackViewBottomConstraint.update(inset: spacing)

        UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveEaseOut, animations: {
            self.overlayView.alpha = 1
            self.dropdownStackView.alpha = 1
            self.dropdownContainer.superview?.layoutIfNeeded()
        }) { (done) in
            self.menuState = .open
        }

    }

    public func closeMenu() {

        titleButton.isSelected = false
        dropdownContainer.snp.updateConstraints { (make) in
            make.height.equalTo(0)
        }
        stackViewBottomConstraint.update(inset: 0)

        UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveEaseOut, animations: {
            self.overlayView.alpha = 0
            self.dropdownStackView.alpha = 0
            self.dropdownContainer.superview?.layoutIfNeeded()
        }) { (done) in
            self.menuState = .closed
            self.dropdownStackView.removeAllArrangedSubviews()
        }
    }

    @objc private func titleButtonPressed(_ sender: Button) {
        switch menuState {
        case .open:
            closeMenu()
        case .closed:
            openMenu()
        }
    }

    @objc private func menuButtonPressed(_ sender: Button) {
        closeMenu()
    }

    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        // Nothing of interest is happening here unless the touch is inside the containerView
        print(UIColor.colorOfPoint(point: point, in: overlayView).cgColor.alpha > 0)
        if UIColor.colorOfPoint(point: point, in: overlayView).cgColor.alpha > 0 {
            return true
        }
        return super.point(inside: point, with: event)
    } }

Extensions.swift

import UIKit

extension UIWindow {
    static var topController: UIViewController? {
        get {
            guard var topController = UIApplication.shared.keyWindow?.rootViewController else { return nil }
            while let presentedViewController = topController.presentedViewController {
                topController = presentedViewController
            }
            return topController
        }
    }
}

public extension UIView {
    var globalPoint: CGPoint? {
        return self.superview?.convert(self.frame.origin, to: nil)
    }

    var globalFrame: CGRect? {
        return self.superview?.convert(self.frame, to: nil)
    }
}

extension UIColor {
    static func colorOfPoint(point:CGPoint, in view: UIView) -> UIColor {

        var pixel: [CUnsignedChar] = [0, 0, 0, 0]

        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)

        let context = CGContext(data: &pixel, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue)

        context!.translateBy(x: -point.x, y: -point.y)

        view.layer.render(in: context!)

        let red: CGFloat   = CGFloat(pixel[0]) / 255.0
        let green: CGFloat = CGFloat(pixel[1]) / 255.0
        let blue: CGFloat  = CGFloat(pixel[2]) / 255.0
        let alpha: CGFloat = CGFloat(pixel[3]) / 255.0

        let color = UIColor(red:red, green: green, blue:blue, alpha:alpha)

        return color
    }
}

extension UIStackView {
    func addArrangedSubviews(_ views: [UIView?]) {
        views.filter({$0 != nil}).forEach({ self.addArrangedSubview($0!)})
    }

    func removeAllArrangedSubviews() {

        let removedSubviews = arrangedSubviews.reduce([]) { (allSubviews, subview) -> [UIView] in
            self.removeArrangedSubview(subview)
            return allSubviews + [subview]
        }

        // Deactivate all constraints
        NSLayoutConstraint.deactivate(removedSubviews.flatMap({ $0.constraints }))

        // Remove the views from self
        removedSubviews.forEach({ $0.removeFromSuperview() })
    }
}
brandonscript
  • 68,675
  • 32
  • 163
  • 220

1 Answers1

1

Silly me, I need to put the hitTest on the overlay view (CutoutView) not the calling view.

class CutoutView: UIView {

    // ...

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        guard UIColor.colorOfPoint(point: point, in: self).cgColor.alpha > 0 else { return nil }
        return super.hitTest(point, with: event)
    }
}
brandonscript
  • 68,675
  • 32
  • 163
  • 220