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.