1
votes

I have a vertical scrolling UIScrollView which spans the entire ViewController, which contains this ContentView (UIView), so I am able to detect touch event outside of the scrollViews bounds:

import UIKit

class ContentView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let reversedSubviews = subviews.reversed()
        let hitSubview = reversedSubviews
            .first(where: { $0.hitTest(convert(point, to: $0), with: event) != nil })
        if hitSubview != nil {
            return hitSubview
        }
        return super.hitTest(point, with: event)
    }
}

This is the View hiearchey

UIScrollView (vertical scroll) //Works
|
 -- ContentView(UIView)
    |
    --UIScrollView (horizontal scroll) //Works
       |
       -- UIView
          |
          --UIButton //This does not work
    |
    --UIScrollView (horizontal scroll) //Works
       |
       -- UIView
          |
          --UIButton //This does not work

Inside this ContentView I have several other horizontal scrolling UIScrollViews. But I am not able to detect touch events inside these scrollViews, because of the ContentView.

How should I approach this to be able to detect touch event in all scrollViews, even if subviews are out of bounds?

It does not help to put ContentView inside horizontal scrollViews..

2
From the description of your layout, it's not clear why you are using hitTest()? If you are placing views / buttons outside of your contentView bounds, you're probably not doing it right (unless you can show / describe why you need to). - DonMag

2 Answers

1
votes

I also faced this issue when I did not had much understanding about UITableView & UICollectionView and how to avoid nesting of two scroll view where scrolling are in same direction (horizontal & horizontal OR vertical & vertical).

This problem happens generally when your internal UI elements are drawn outside of the available view port(the frame which is rendered on screen) of any screen view. Sometimes it happens due to issues with constraints set up.

How to debug?

To quickly debug, identify the parent UIViews of the buttons which are not responding. Check Clips to Bounds from Identity Inspector which is false by default. Which Clips to Bounds true, you are only seeing the views which you can interact with.

You can check the issue in the below YouTube Video:

https://youtu.be/KMxFu85sXnc

Solution:

  1. Revisit the constraints or frame calculations for the parent views.
  2. UICollectionView (horizontal scroll) inside another UICollectionView or UITableView(vertical scroll) might be a good approach if you are not following that.
1
votes

Here's a pretty basic example.

Following your hierarchy:

  Main View
  |
  -- UIScrollView (vertical scroll) - Red
    |
     -- ContentView(UIView) - Medium Blue
        |
        -- UIScrollView (horizontal scroll) - Green
           |
           -- UIView - Light Gray
              |
              -- UIButton - Blue
           |
           -- UIView - Light Gray
              |
              -- UIButton - Blue
        |
        -- UIScrollView (horizontal scroll) - Green
           |
           -- UIView - Light Gray
              |
              -- UIButton - Blue
           |
           -- UIView - Light Gray
              |
              -- UIButton - Blue
        |

This is how it looks - buttons can be tapped:

enter image description here

and, after scrolling down and scrolling each horizontal scroll view to the right:

enter image description here

Example code... no @IBOutlet or @IBAction connections needed, just assign a view controller's class to MultiScrollViewController:

class MultiScrollViewController: UIViewController {
    
    let vertScrollView = UIScrollView()
    
    let contentView = UIView()
    
    let hScrollA = UIScrollView()
    let hScrollB = UIScrollView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        [vertScrollView, contentView, hScrollA, hScrollB].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
        }
        
        contentView.addSubview(hScrollA)
        contentView.addSubview(hScrollB)
        
        vertScrollView.addSubview(contentView)
        
        view.addSubview(vertScrollView)
        
        let g = view.safeAreaLayoutGuide
        let svContentG = vertScrollView.contentLayoutGuide
        let svFrameG = vertScrollView.frameLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // vertical scroll view with 20-pts on each side for "padding"
            vertScrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            vertScrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            vertScrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            vertScrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
            
            // content view Top / Leading / Bottom 12-pts to content layout guide
            contentView.topAnchor.constraint(equalTo: svContentG.topAnchor, constant: 12.0),
            contentView.leadingAnchor.constraint(equalTo: svContentG.leadingAnchor, constant: 12.0),
            contentView.bottomAnchor.constraint(equalTo: svContentG.bottomAnchor, constant: -12.0),
            // content view Trailing to content layout guide
            contentView.trailingAnchor.constraint(equalTo: svContentG.trailingAnchor, constant: 0.0),
            
            // content view Width: scroll frame width minus 24-pts (we don't want horizontal scrolling)
            contentView.widthAnchor.constraint(equalTo: svFrameG.widthAnchor, constant: -24.0),
            
            // horizontal scroll view A Top / Leading / Trailing 8-pts to contentView
            hScrollA.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8.0),
            hScrollA.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
            hScrollA.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
            
            // horizontal scroll view B Top 20-pts from Bottom of hScrollA
            hScrollB.topAnchor.constraint(equalTo: hScrollA.bottomAnchor, constant: 20.0),
            
            // horizontal scroll view B Leading / Trailing / Bottom 8-pts to contentView
            hScrollB.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
            hScrollB.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
            hScrollB.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8.0),
            
            // so we have some vertical scrolling, make each horizontal scroll view
            //  60% the height of the vertical scroll view frame
            hScrollA.heightAnchor.constraint(equalTo: svFrameG.heightAnchor, multiplier: 0.6),
            hScrollB.heightAnchor.constraint(equalTo: hScrollA.heightAnchor),
            
        ])
        
        // each horizontal scroll view will have
        //  two UIViews, each with a UIButton
        
        let btn1inA = UIButton()
        let btn2inA = UIButton()
        
        let btn1inB = UIButton()
        let btn2inB = UIButton()
        
        let btn1inAView = UIView()
        let btn2inAView = UIView()
        
        let btn1inBView = UIView()
        let btn2inBView = UIView()
        
        let btnViews = [btn1inAView, btn2inAView, btn1inBView, btn2inBView]
        
        let btns = [btn1inA, btn2inA, btn1inB, btn2inB]
        let ttls = ["Button 1 in A", "Button 2 in A", "Button 1 in B", "Button 2 in B"]
        
        for (btn, str) in zip(btns, ttls) {
            btn.setTitle(str, for: [])
            btn.setTitleColor(.white, for: .normal)
            btn.setTitleColor(.gray, for: .highlighted)
            btn.titleLabel?.font = .boldSystemFont(ofSize: 18)
            btn.backgroundColor = .blue
            btn.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
            btn.translatesAutoresizingMaskIntoConstraints = false
            btn.addTarget(self, action: #selector(self.btnTap(_:)), for: .touchUpInside)
        }
        
        for (btn, v) in zip(btns, btnViews) {
            v.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            v.translatesAutoresizingMaskIntoConstraints = false
            v.addSubview(btn)
            // center each button in a view with 8-pts padding on all 4 sides
            NSLayoutConstraint.activate([
                btn.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
                btn.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
                btn.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
                btn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
            ])
        }
        
        // add button-holding-views to horizontal scroll views
        hScrollA.addSubview(btn1inAView)
        hScrollA.addSubview(btn2inAView)
        
        hScrollB.addSubview(btn1inBView)
        hScrollB.addSubview(btn2inBView)
        
        let svAContentG = hScrollA.contentLayoutGuide
        let svAFrameG = hScrollA.frameLayoutGuide
        
        let svBContentG = hScrollB.contentLayoutGuide
        let svBFrameG = hScrollB.frameLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // button 1 in hScroll A
            //  top == content top + 20
            btn1inAView.topAnchor.constraint(equalTo: svAContentG.topAnchor, constant: 20.0),
            //  leading == content leading + 20
            btn1inAView.leadingAnchor.constraint(equalTo: svAContentG.leadingAnchor, constant: 20.0),
            
            // button 2 in hScroll A
            //  leading == button 1 leading + 600 (so we get horizontal scrolling
            btn2inAView.leadingAnchor.constraint(equalTo: btn1inAView.trailingAnchor, constant: 600.0),
            
            //  trailing == content trailing - 20
            btn2inAView.trailingAnchor.constraint(equalTo: svAContentG.trailingAnchor, constant: -20.0),
            
            //  bottom to content bottom
            btn2inAView.bottomAnchor.constraint(equalTo: svAContentG.bottomAnchor),
            
            //  bottom 20-pts from hScroll A frame bottom (so it's at lower-right)
            btn2inAView.bottomAnchor.constraint(equalTo: svAFrameG.bottomAnchor, constant: -20.0),
            
            // button 1 in hScroll B
            //  top == content top + 20
            btn1inBView.topAnchor.constraint(equalTo: svBContentG.topAnchor, constant: 20.0),
            
            //  leading == content leading + 20
            btn1inBView.leadingAnchor.constraint(equalTo: svBContentG.leadingAnchor, constant: 20.0),
            
            // button 2 in hScroll A
            //  leading == button 1 leading + 600 (so we get horizontal scrolling
            btn2inBView.leadingAnchor.constraint(equalTo: btn1inBView.trailingAnchor, constant: 600.0),
            
            //  trailing == content trailing - 20
            btn2inBView.trailingAnchor.constraint(equalTo: svBContentG.trailingAnchor, constant: -20.0),
            
            //  bottom to content bottom
            btn2inBView.bottomAnchor.constraint(equalTo: svBContentG.bottomAnchor),
            
            //  bottom 20-pts from hScroll B frame bottom (so it's at lower-right)
            btn2inBView.bottomAnchor.constraint(equalTo: svBFrameG.bottomAnchor, constant: -20.0),
            
        ])
        
        // some background colors so we can see the frames
        view.backgroundColor = .yellow
        vertScrollView.backgroundColor = .red
        
        // medium blue
        contentView.backgroundColor = UIColor(red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0)
        
        hScrollA.backgroundColor = .green
        hScrollB.backgroundColor = .green
        
    }
    
    @objc func btnTap(_ sender: UIButton) -> Void {
        print(sender.currentTitle ?? "No Button Title")
    }
    
}