background

With the continuous iteration of product functions, there is always a need to switch different content display according to the selector in one area without affecting the function of other areas.

Apple doesn’t recommend nested scrollviews, and if you add them directly, you’ll get something like the one below, where clashing gestures makes for an experience of tragedy.

In the actual development, I was also constantly thinking about solutions. After several reconstructions, I had some experience of improvement. Therefore, I took time to sort out three solutions, and they achieved the same final effect.


Divide and conquer

The most common approach is to use a UITableView as an external framework to render the contents of a subview as a UITableView cell.

The benefit of this approach is decoupling, as the framework can refresh the corresponding content simply by accepting different data sources.

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) 
    -> CGFloat {
    if indexPath.section == 0 {
        return NSTHeaderHeight
    }
    
    if segmentView.selectedIndex == 0 {
        return tableSource.tableView(_:tableView, heightForRowAt:indexPath)
    }
    
    return webSource.tableView(_:tableView, heightForRowAt:indexPath)
}
Copy the code

But there’s a problem, if you have a separate scroll view inside, like UIWebView’s child UIWebScrollView, you’re still going to have gesture conflicts.

The general practice is to first disable scrolling in the internal view, and when you scroll to the location of the page, start scrolling on the page and disable external scrolling, and vice versa.

Unfortunately, the biggest problem with this approach is frustration.

The inner view is not scrollable initially, so the outer view acts as the receiver of the whole set of events. When scrolling reaches the preset position and the inner view is enabled, the event is still passed to the external view, the only recipient, and the inner view can only start scrolling if it is re-triggered after releasing its hand to end the event.

Fortunately, there is a solution to this problem.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    if scrollView == tableView {
        // The outside is rolling
        if offset > anchor {
            // Roll past the anchor point, restore the external view position, add the offset to the internal
            tableView.setContentOffset(CGPoint(x: 0, y: anchor), animated: false)
            let webOffset = webScrollView.contentOffset.y + offset - anchor
            webScrollView.setContentOffset(CGPoint(x: 0, y: webOffset), animated: false)}else if offset < anchor {
            // Do not roll to anchor point, restore position
            webScrollView.setContentOffset(CGPoint.zero, animated: false)}}else {
        // The inside is rolling
        if offset > 0 {
            // Inner scroll to restore the outer position
            tableView.setContentOffset(CGPoint(x: 0, y: anchor), animated: false)}else if offset < 0 {
            // Roll up inside and add the offset to the external view
            let tableOffset = tableView.contentOffset.y + offset
            tableView.setContentOffset(CGPoint(x: 0, y: tableOffset), animated: false)
            webScrollView.setContentOffset(CGPoint.zero, animated: false)}}}func scrollViewDidEndScroll(_ scrollView: UIScrollView) {
    // Calculate who can scroll according to the offset after scrolling stops
    var outsideScrollEnable = true
    if scrollView == tableView {
        if offset == anchor &&
            webScrollView.contentOffset.y > 0 {
            outsideScrollEnable = false
        } else {
            outsideScrollEnable = true}}else {
        if offset == 0 &&
            tableView.contentOffset.y < anchor {
            outsideScrollEnable = true
        } else {
            outsideScrollEnable = false}}// Set scroll to display the corresponding scroll bartableView.isScrollEnabled = outsideScrollEnable tableView.showsHorizontalScrollIndicator = outsideScrollEnable webScrollView.isScrollEnabled = ! outsideScrollEnable webScrollView.showsHorizontalScrollIndicator = ! outsideScrollEnable }Copy the code

By accepting the scroll callback, we can artificially control the scrolling behavior. When the scrolling distance exceeds our preset value, we can set another view’s offset to simulate the scrolling effect. After the scrolling status is complete, you can determine which view can be rolled based on the judgment.

Of course, to use this method, we would have to set the proxies for both scroll views to be controllers, which might affect the code logic (UIWebView is the proxy for UIWebScrollView; see the solution below).

UITableView nesting is a great way to handle simple nested views, but you can also handle complex situations like UIWebView for control. However, as a ring of UITableView, there are many limitations (for example, different data sources need different Settings, some want dynamic heights, and some need to insert additional views), which can’t be solved very well.


fragmented

The other solution is more reactionary and is inspired by the implementation of the pull-down refresh, in which the content to be displayed is crammed into a negative screen.

Make sure the subview fills the screen, insert the content of the main view into the subview, and set the ContentInset to the head height.

Let’s look at the code implementation.

func reloadScrollView(a) {
    // Select the view that is currently displayed
    let scrollView = segmentView.selectedIndex == 0 ? 
        tableSource.tableView : webSource.webView.scrollView
    // Do not operate on the same view
    if currentScrollView == scrollView {
        return
    }
    // Remove external content from the previous view
    headLabel.removeFromSuperview()
    segmentView.removeFromSuperview()
    ifcurrentScrollView ! =nil{ currentScrollView! .removeFromSuperview() }// Sets the inline offset of the new scroll view to the height of the external content
    scrollView.contentInset = UIEdgeInsets(top: 
        NSTSegmentHeight + NSTHeaderHeight.left: 0, bottom: 0.right: 0)
    // Add external content to the new view
    scrollView.addSubview(headLabel)
    scrollView.addSubview(segmentView)
    view.addSubview(scrollView)
    
    currentScrollView = scrollView
}
Copy the code

Since there is only one scrolling view at the UI level, conflicts are cleverly avoided.

In contrast, the inserted header view must be lightweight, and if you want to achieve the floating bar effect as in my example, you need to watch the offset change and position it manually.

func reloadScrollView(a) {
    ifcurrentScrollView ! =nil{ currentScrollView! .removeFromSuperview()// Remove the previous KVOobserver? .invalidate() observer =nil
    }

    // Add scroll view to new view
    observer = scrollView.observe(\.contentOffset, options: [.new, .initial]) 
    {[weak self] object, change in
        guard let strongSelf = self else {
            return
        }
        let closureScrollView = object as UIScrollView
        var segmentFrame = strongSelf.segmentView.frame
        // Calculate the offset position
        let safeOffsetY = closureScrollView.contentOffset.y + 
            closureScrollView.safeAreaInsets.top
        // Calculate the floating bar position
        if safeOffsetY < -NSTSegmentHeight {
            segmentFrame.origin.y = -NSTSegmentHeight
        } else {
            segmentFrame.origin.y = safeOffsetY
        }
        strongSelf.segmentView.frame = segmentFrame
    }
}
Copy the code

This method has a pit in it. If the loaded UITableView needs to display its own SectionHeader, the floating position will be offset due to ContentInset.

The solution I came up with was to constantly tweak the ContentInset in a callback.

observer = scrollView.observe(\.contentOffset, options: [.new, .initial]) 
{[weak self] object, change in
    guard let strongSelf = self else {
        return
    }
    let closureScrollView = object as UIScrollView
    // Calculate the offset position
    let safeOffsetY = closureScrollView.contentOffset.y + 
        closureScrollView.safeAreaInsets.top
    //ContentInset is customized based on the current scroll
    var contentInsetTop = NSTSegmentHeight + NSTHeaderHeight
    if safeOffsetY < 0 {
        contentInsetTop = min(contentInsetTop, fabs(safeOffsetY))
    } else {
        contentInsetTop = 0
    }
    closureScrollView.contentInset = UIEdgeInsets(top: 
    contentInsetTop, left: 0, bottom: 0.right: 0)}Copy the code

The advantage of this approach is that there is only one scroll view and all gestures are implemented natively, reducing possible linkage problems.

However, there is also a small defect, that is, the offset of the header content is negative, which is not conducive to the implementation of three-party call and system original call, need to maintain.


A centralized

Finally, a more perfect scheme is introduced. The external view adopts UIScrollView, and the internal view cannot scroll forever. The external view adjusts the internal position while scrolling, ensuring the independence of both sides.

Compared with the second method, switching different functions is relatively simple, just need to replace the internal view, and implement the proxy of the external view, set the offset of the internal view when scrolling.

func reloadScrollView(a) {
    // Get the current data source
    let contentScrollView = segmentView.selectedIndex == 0 ? 
    tableSource.tableView : webSource.webView.scrollView
    // Remove the previous view
    ifcurrentScrollView ! =nil{ currentScrollView! .removeFromSuperview() }// Disable adding new views after scrolling
    contentScrollView.isScrollEnabled = false
    scrollView.addSubview(contentScrollView)
    // Save the current view
    currentScrollView = contentScrollView
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    // Refresh the position of the Segment and internal view based on the offset
    self.view.setNeedsLayout()
    self.view.layoutIfNeeded()
    // Calculate the offset of the internal view from the external view data
    var floatOffset = scrollView.contentOffset
    floatOffset.y -= (NSTHeaderHeight + NSTSegmentHeight)
    floatOffset.y = max(floatOffset.y, 0)
    // Synchronize the offset of the internal view
    ifcurrentScrollView? .contentOffset.equalTo(floatOffset) ==false{ currentScrollView? .setContentOffset(floatOffset, animated:false)}}override func viewDidLayoutSubviews(a) {
    super.viewDidLayoutSubviews()
    // Full
    scrollView.frame = view.bounds
    // Head fixed
    headLabel.frame = CGRect(x: 15, y: 0, 
        width: scrollView.frame.size.width - 30, height: NSTHeaderHeight)
    // The Segment position is the maximum offset and header height
    // Ensure that the scroll to the head position does not float
    segmentView.frame = CGRect(x: 0, 
        y: max(NSTHeaderHeight, scrollView.contentOffset.y), 
        width: scrollView.frame.size.width, height: NSTSegmentHeight)
    // Adjust the position of the internal view
    ifcurrentScrollView ! =nil{ currentScrollView? .frame =CGRect(x: 0, y: segmentView.frame.maxY, 
            width: scrollView.frame.size.width, 
            height: view.bounds.size.height - NSTSegmentHeight)}}Copy the code

When the external view starts scrolling, it is always adjusting the position of the inner view based on the offset.

The content height of the external view is not fixed, but the content height of the internal view plus the head height, so watch for changes and refresh.

func reloadScrollView(a) {
    ifcurrentScrollView ! =nil {
        / / removing KVOobserver? .invalidate() observer =nil
    }

    // Add content size KVO
    observer = contentScrollView.observe(\.contentSize, options: [.new, .initial]) 
    {[weak self] object, change in
        guard let strongSelf = self else {
            return
        }
        let closureScrollView = object as UIScrollView
        let contentSizeHeight = NSTHeaderHeight + NSTSegmentHeight + 
            closureScrollView.contentSize.height
        // When the content size changes, refresh the total size of the external view to ensure scrolling distance
        strongSelf.scrollView.contentSize = CGSize(width: 0, height: contentSizeHeight)
    }
}
Copy the code

There is also a problem with this method, as the internal scrolling is all done externally without gesture participation, there is no scrollViewDidEndDragging or other scroll callbacks, which can be difficult if needs such as page flipping are involved.

The solution is to get the internal view’s original proxy, and when the external view proxy receives a callback, forward it to the proxy to implement the functionality.

func reloadScrollView(a) {
    typealias ClosureType = @convention(c) (AnyObject.Selector) - >AnyObject
    // Define the get proxy method
    let sel = #selector(getter: UIScrollView.delegate)
    // Get the implementation of the scroll view proxy
    let imp = class_getMethodImplementation(UIScrollView.self, sel)
    // Wrap it as a closure
    let delegateFunc : ClosureType = unsafeBitCast(imp, to: ClosureType.self)
    // Get the actual proxy object
    currentScrollDelegate = delegateFunc(contentScrollView, sel) as? UIScrollViewDelegate
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    ifcurrentScrollDelegate ! =nil{ currentScrollDelegate! .scrollViewDidEndDragging? (currentScrollView! , willDecelerate: decelerate) } }Copy the code

Note here I didn’t use contentScrollView. Delegate, this is because the UIWebScrollView overloading the method and returns the UIWebView agent. But the real proxy is an NSProxy object that passes callbacks to UIWebView and the external proxy. To ensure that UIWebView can handle properly, it needs to receive the callback as well, so use the Runtime to implement the original fetch agent for UIScrollView.


conclusion

I currently use this last approach in production environments, but there are strengths and weaknesses to each approach.

plan Divide and conquer fragmented A centralized
way nested embedded nested
linkage manual automatic manual
switch The data source The overall change Local changes
advantage Easy to understand Good scrolling effect independence
disadvantage Complex linkage Complex scenes are hard to handle Simulated rolling hazard
score 🌟 🌟 🌟 🌟 🌟 🌟 🌟 🌟 🌟 🌟 🌟

There is no right or wrong technology, only the right or wrong technology for the needs of the moment.

Divide-and-conquer works well when uITableViews are nested within each other, and switching between data sources works well.

Separate pages are suitable for relatively simple pages, and you can get the best scrolling if you can avoid floating boxes.

Centralization is suitable for complex scenarios, with separate types of scrolling views to minimize interaction, but needs to be handled with care due to its simulative scrolling nature.

Hope this article can give you inspiration, the project open source code is here, welcome to advise and Star.