skip to Main Content

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.

Is there a way?


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


A further challenge is the background buttons should "work fully properly in all phases", ie if you eg. hold down on one but then slide off it.

2

Answers


  1. Chosen as BEST ANSWER

    Wow just wow.

    Here's a demo of the mind-blowing @HangarRash Solution:

    enter image description here

    Here is the complete Xcode project:

    https://ufile.io/a3jkcdj2

    enter image description here

    Interesting further issue:

    Go to the view controller. Notice I set up some storyboard cells, which use the @HangarRash Technology™

    Just toggle the code to use the storyboard cells ...

    enter image description here

    (Or use code like if indexPath.item % 3 == 0 ... to show a few of both cells on screen at the same time.)

    Notice "my" cells don't quite work in all situations.

    I can't for the life of me figure out what I'm doing wrong, as the HR solution is clear and I seem to be implementing the steps needed. Maybe someone can see the issue.

    Incredible solution! Note in particular the HR solution even achieves the "further challenge" mentioned at the end of the question.


    zip file download link may expire in 30 days - I know nothing about the web and file sharing - feel free to repost freely or edit this post or link freely


  2. 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. 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.

    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.
    // 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.

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