Search code examples
iosswiftuitableviewuikitevent-bubbling

How to allow a CLICK, through a UITableView, to buttons behind?


Imagine a large table view that's mostly transparent, with some elements here and there. (Perhaps there's a large gap between cells, or, picture the cells being mainly transparent with only a couple buttons or such.

Behind this mostly-transparent table is some other materials, say with some buttons.

How to make the table that

  • if you scroll it scrolls normally

  • if you tap on a button on a cell, that works normally

  • but if you tap on one of the buttons behind the table, the click is passed through and affects that button?

(By "click" I mean what we now call a "primary action triggered" - a "click on a UIButton".)

There are a number of different well-known techniques for passing touches (as such) through views in different situations,

etc.

But I've never been able to get the above three mentioned conditions working.

Summary: enable clicking on a UIButton behind a UITableView.

The "background" buttons should work fully correctly as buttons in all phases, ie if you eg. hold down but then slide off.

Is there a way?


It occurs to me that passing clicks through a scroll view, to buttons behind, is an almost identical problem.


Solution

  • The following code demonstrates the ability to have a table view with a transparent background that allows you to tap on controls in the table view rows, it allows the table view to be scrolled, it allows table view rows to be selected, and it allows for controls behind the table view to be tapped as long as the tap is outside of any controls in a table view row.

    The demonstration makes use of modern cell configuration using a custom UIContentConfiguration and custom UIContentView (modeled after this answer). It also makes use of a custom UITableView subclass.

    Both the custom table view subclass and the custom cell content view implement custom hit testing based on the solution provided by Pass touches through a UIViewController but with some modification.

    Begin by creating a new iOS app project. Setup the project to be based on Swift and Storyboard.

    The following code contains lots of comments. The majority of the code below is to setup a working demonstration. The important code is the custom hitTest method in PassTableView and ButtonContentView. Just about everything can be changed as needed except those two methods. Note that the implementation doesn't depend on the table view transparency. That just allows you to see the controls behind the table view.

    Add a new Swift file named PassTableView.swift with the following contents:

    import UIKit
    
    // This subclass of UITableView allows touches to be delegated to another view.
    // Only touches that are not used by the cell contents are delegated.
    // The table view cells also need to implement the same logic.
    // Having the logic in both the cells and the table view allows touches to be delegated if the
    // user taps on a cell or if the user taps on an area of the table view not covered by a cell.
    class PassTableView: UITableView {
        weak var touchDelegate: UIView? = nil
    
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            guard let view = super.hitTest(point, with: event) else {
                return nil
            }
    
            guard view === self, let point = touchDelegate?.convert(point, from: self) else {
                return view
            }
    
            // If the passthrough view returns a specific view then return that subview
            // If the passthrough view returns itself, then return the view that would normally be returned.
            // Without that last test, table view scrolling and cell selection is disabled.
            if let subview = touchDelegate?.hitTest(point, with: event), subview !== touchDelegate {
                return subview
            } else {
                return view
            }
        }
    }
    

    Add another Swift file named ButtonCell.swift with the following contents:

    import UIKit
    
    fileprivate class ButtonCellView: UIView, UIContentView {
        var configuration: UIContentConfiguration {
            didSet {
                configure(configuration: configuration)
            }
        }
    
        private var button = UIButton()
    
        init(configuration: UIContentConfiguration) {
            self.configuration = configuration
    
            super.init(frame: .zero)
    
            // Give the cell content a semi-transparent background
            // This depends on the table view having a clear background
            // Optionally, set this to .clear and give the table view a transparent background
            backgroundColor = .systemBackground.withAlphaComponent(0.5)
    
            let cfg = UIButton.Configuration.borderedTinted()
            button = UIButton(configuration: cfg, primaryAction: UIAction(handler: { action in
                print("Button \(self.button.configuration?.title ?? "?") tapped")
            }))
            button.translatesAutoresizingMaskIntoConstraints = false
            addSubview(button)
    
            NSLayoutConstraint.activate([
                button.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
                button.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
                button.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
            ])
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        func configure(configuration: UIContentConfiguration) {
            guard let configuration = configuration as? ButtonCellConfiguration else { return }
    
            touchDelegate = configuration.touchDelegate
            
            var cfg = button.configuration
            cfg?.title = configuration.title
            button.configuration = cfg
        }
    
        weak var touchDelegate: UIView? = nil
    
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            guard let view = super.hitTest(point, with: event) else {
                return nil
            }
    
            guard view === self, let point = touchDelegate?.convert(point, from: self) else {
                return view
            }
    
            // If the passthrough view returns a specific view then return that subview
            // If the passthrough view returns itself, then return the view that would normally be returned.
            // Without that last test, table view scrolling and cell selection is disabled.
            if let subview = touchDelegate?.hitTest(point, with: event), subview !== touchDelegate {
                return subview
            } else {
                return view
            }
        }
    }
    
    struct ButtonCellConfiguration: UIContentConfiguration {
        var title: String // Used as the button title
        weak var touchDelegate: UIView? = nil // The passthrough view to pass touches to
    
        func makeContentView() -> UIView & UIContentView {
            return ButtonCellView(configuration: self)
        }
        
        func updated(for state: UIConfigurationState) -> ButtonCellConfiguration {
            return self
        }
    }
    

    Last, replace the contents of the provided ViewController.swift with the following:

    import UIKit
    
    class ViewController: UIViewController {
        // Use the custom table view subclass so we can support custom hit testing
        private lazy var tableView: PassTableView = {
            let tv = PassTableView(frame: .zero, style: .plain)
            tv.dataSource = self
            tv.delegate = self
            tv.register(UITableViewCell.self, forCellReuseIdentifier: "buttonCell")
            tv.allowsSelection = true
            return tv
        }()
    
        // This view acts as the touch delegate for the table view and the cell content.
        // This view should contain all of the controls you need to handle behind the transparent table view.
        // You need to use this extra view since using the table view's superview (self.view)
        // as the touch delegate results in infinite recursion in the hitTests.
        private lazy var viewLayer: UIView = {
            let v = UIView()
            return v
        }()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .systemRed // Pick a color
    
            // Fill the view controller with the view layer. Adjust as desired.
            viewLayer.frame = view.bounds
            viewLayer.autoresizingMask = [ .flexibleWidth, .flexibleHeight ]
            view.addSubview(viewLayer)
    
            // Add two buttons to the view layer
            // The first will be behind rows of the tableview
            var cfg = UIButton.Configuration.borderedTinted()
            cfg.title = "Background1"
            let button1 = UIButton(configuration: cfg, primaryAction: UIAction(handler: { action in
                print("Background1 button tapped")
            }))
            button1.translatesAutoresizingMaskIntoConstraints = false
            viewLayer.addSubview(button1)
    
            // The second button will be below the last row (on most devices) but still behind the table view.
            // This lets us test touch delegation for buttons behind a row in the table view and for buttons
            // behind just the table view.
            cfg = UIButton.Configuration.borderedTinted()
            cfg.title = "Background2"
            let button2 = UIButton(configuration: cfg, primaryAction: UIAction(handler: { action in
                print("Background2 button tapped")
            }))
            button2.translatesAutoresizingMaskIntoConstraints = false
            viewLayer.addSubview(button2)
    
            // Position the two background buttons
            NSLayoutConstraint.activate([
                button1.trailingAnchor.constraint(equalTo: viewLayer.layoutMarginsGuide.trailingAnchor),
                button1.centerYAnchor.constraint(equalTo: viewLayer.centerYAnchor),
                button2.trailingAnchor.constraint(equalTo: viewLayer.layoutMarginsGuide.trailingAnchor),
                button2.bottomAnchor.constraint(equalTo: viewLayer.safeAreaLayoutGuide.bottomAnchor),
            ])
    
            // Setup the table view's touch delegate
            tableView.touchDelegate = self.viewLayer
            // Either set the table view background to clear and the cell content to some transparent color, or
            // set the table view background to a transparent color and the cell content to clear.
            tableView.backgroundColor = .clear
            // Fill the view controller with the table view. Adjust as desired.
            tableView.frame = view.bounds
            tableView.autoresizingMask = [ .flexibleWidth, .flexibleHeight ]
            view.addSubview(tableView)
        }
    }
    
    extension ViewController: UITableViewDataSource, UITableViewDelegate {
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return 10 // Partially fill the table view with rows (on most devices). Change as needed.
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "buttonCell", for: indexPath)
    
            // Use modern cell configuration
            // This is where we set the cell's button title and touch delegate
            let cfg = ButtonCellConfiguration(title: "Button \(indexPath.row)", touchDelegate: self.viewLayer)
            cell.contentConfiguration = cfg
            // Ensure the cell has a clear background
            cell.backgroundConfiguration = .clear()
    
            return cell
        }
    
        // Demonstrate that cell selection still works as long as the user does not tap on
        // any buttons (on the cells or behind the table view).
        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            print("Selected row \(indexPath)")
    
            tableView.deselectRow(at: indexPath, animated: true)
        }
    }
    

    The code supports iOS 15+. Adjust your app's deployment target as needed.

    Build and run the app. You will see a table view with 10 rows, each containing a button. You will also see two other buttons labeled "Background ButtonX". The two extra buttons are behind the transparent table view.

    All table view interactions work as expected including scrolling and cell selection. Tapping any button, including the two behind the table view, will print a message to the console.

    I already state this in the code comments but it is worth repeating. It's critical that the view passed to the touchDelegate used by the table view and the cells must not be in the table view's superview hierarchy, such as self.view. The touchDelegate must be a sibling (or cousin) view. Violating this condition will lead to infinite recursion when tapping outside of a control in a cell.


    This general solution also works with UIScrollView. Create a PassScrollView subclass with the same code as PassTableView. That's it. It's much simpler since you don't need to deal with table cells.

    import UIKit
    
    // This subclass of UIScrollView allows touches to be delegated to another view.
    // This allows touches to be delegated if the user taps anywhere within
    // the scroll view that doesn't contain a control or other view.
    class PassScrollView: UIScrollView {
        weak var touchDelegate: UIView? = nil
    
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            guard let view = super.hitTest(point, with: event) else {
                return nil
            }
    
            guard view === self, let point = touchDelegate?.convert(point, from: self) else {
                return view
            }
    
            // If the passthrough view returns a specific view then return that subview
            // If the passthrough view returns itself, then return the view that would normally be returned.
            // Without that last test, scrolling is disabled.
            if let subview = touchDelegate?.hitTest(point, with: event), subview !== touchDelegate {
                return subview
            } else {
                return view
            }
        }
    }