I'm building a collectionview. Below of it I placed some buttons as shown in the picture.
What I want is to make the UICollectionView background pass taps below, so the desired buttons can receive taps.
I don't need to add Tap gesture recognizers to the background view (the problem I'm describing is just an example here), I need the buttons' actuons to be triggered directly when they're tapped.
I thought I could do this by making the background clear or disabling user interaction for the background view. While disabling it for the entire collection view works, this other way does not.
How can I make the background view of my collectionView be "invisible" so that taps go straight to the below buttons instead of going to the collectionview background?
The following is an example of my layout.
Assuming your collectionView and your buttons share the same superview, this should do the trick.
What you want to do is bypass the backgroundView and forward hits to the subviews underneath the collectionView.
Notice that we are picking the last subview with the matching criteria. That is because the last subview in the array is the closest to the user's finger.
class SiblingAwareCollectionView: UICollectionView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hit = super.hitTest(point, with: event)
guard hit === backgroundView else {
return hit
}
let sibling = superview?
.subviews
.filter { $0 !== self }
.filter { $0.canHit }
.last { $0.point(inside: convert(point, to: $0), with: event) }
return sibling ?? hit
}
}
If you look at the documentation for hitTest(_:with:) it says:
This method ignores view objects that are hidden, that have disabled user interactions, or have an alpha level less than 0.01.
For convenience, here is an extension to ensure we are playing by the rules:
extension UIView {
var canHit: Bool {
!isHidden && isUserInteractionEnabled && alpha >= 0.01
}
}
I have a UICollectionView called dayPicker that scrolls horizontally, and lets you select the day of the month. When the user stops scrolling (scrollViewDidEndDecelerating), I want the app to do something with that day, accessible from the cell's label. All of the answers I have seen online are similar to this: https://stackoverflow.com/a/33178797/9036092
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
var someCell : UICollectionViewCell = collectionView.visibleCells()[0];
// Other code follows...
}
When I try to access collectionView from inside the scrollViewDidEndDecelerating function, I get a Ambiguous use of collectionView error. When I substitute the actual name of my UICollectionView (dayPicker), it errors out with "Unexpectedly found nil while implicitly unwrapping an Optional value".
My question is: how do you get to collectionView from inside the scrollViewDidSomething function? Currently my scrollViewDidEndDecelerating function is inside a UICollectionViewDelegate in my view controller, and I have also tried putting it in a UIScrollViewDelegate extension.
Current code:
extension PageOneViewController: UICollectionViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let centerPoint = CGPoint(x: UIScreen.main.bounds.midX, y: scrollView.frame.midY)
print(centerPoint) // Successfully prints the same center point every time scrolling stops
let indexPath = collectionView.indexPathForItem(at: centerPoint) // Ambiguous error
let indexPath = dayPicker.indexPathForItem(at: centerPoint) // Fatal error
}
}
Screenshot of scrollable UICollectionView in question:
I also have another method of when the user taps on the day, and it is working flawlessly. Trying to complete the experience with the scrolling ending.
Xcode 11.4.1/Swift 5
I figured it out, for those who come across this thread looking for the same answer. Big thanks to this answer here for a different issue: https://stackoverflow.com/a/45385718/9036092
The missing ingredient was to cast scrollView as a UICollectionView so that you can access the collectionView's cell properties.
Working code:
extension PageOneViewController: UICollectionViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let collectionView = scrollView as! UICollectionView // This is crucial
let centerPoint = CGPoint(x: UIScreen.main.bounds.midX, y: scrollView.frame.midY)
let indexPath = collectionView.indexPathForItem(at: centerPoint)
let centerCell = collectionView.cellForItem(at: indexPath!) as! MyCustomCell
let selectedDay = centerCell.dayLabel.text //
print(selectedDay) // Prints the value of the day in the center of the collectionView, as a string
}
}
In the video Advances in Collection View Layout - WWDC 2019, Apple introduces a new 'orthogonal scrolling behavior' feature. I have a view controller almost identical to OrthogonalScrollingViewController in their example code. In particular my collection view is laid out vertically, and each section can scroll horizontally (I use section.orthogonalScrollingBehavior = .groupPaging).
I want to have all my sections scroll horizontally in unison. Previously, I listened for scrollViewDidScroll on each horizontal collection view, then manually set the content offset of the others. However, with the new orthogonalScrollingBehavior implementation, scrollViewDidScroll never gets called on the delegate when I scroll horizontally. How can I detect horizontal scrolling events with the new API?
If there's another way to make the sections scroll together horizontally, I'm also open to other suggestions.
You can use this callback:
let section = NSCollectionLayoutSection(group: group)
section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, env) in
}
As mentioned you can use visibleItemsInvalidationHandler which provides the location of the scroll offset.
You can detect if a page changed by getting the modulus of the page width. You need to additionally supply a tolerance to ignore halfway scroll changes.
Im using this:
class CollectionView: UICollectionViewController {
private var currentPage: Int = 0 {
didSet {
if oldValue != currentPage {
print("The page changed to \(currentPage)")
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Configure layout...
let itemSize = NSCollectionLayoutSize...
let item = NSCollectionLayoutItem...
let groupSize = NSCollectionLayoutSize...
let group = NSCollectionLayoutGroup.horizontal...
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
// Use visibleItemsInvalidationHandler to make calculations
section.visibleItemsInvalidationHandler = { [weak self] items, location, environment in
guard let self = self else { return }
let width = self.collectionView.bounds.width
let scrollOffset = location.x
let modulo = scrollOffset.truncatingRemainder(dividingBy: width)
let tolerance = width/5
if modulo < tolerance {
self.currentPage = Int(scrollOffset/width)
}
}
self.collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
}
}
Here's a hacky solution. Once you render your orthogonal section, you can access it via the subviews on your collectionView. You can then check if the subview is subclass of UIScrollView and replace the delegate.
collectionView.subviews.forEach { (subview) in
if let v = subview as? UIScrollView {
customDelegate.originalDelegate = v.delegate!
v.delegate = customDelegate
}
}
One tricky bit is that you want to capture its original delegate. The reason for this is because I notice that you must call originalDelegate.scrollViewDidScroll(scrollView) otherwise the section doesn't render out completely.
In other word something like:
class CustomDelegate: NSObject, UIScrollViewDelegate {
var originalDelegate: UIScrollViewDelegate!
func scrollViewDidScroll(_ scrollView: UIScrollView) {
originalDelegate.scrollViewDidScroll?(scrollView)
}
}
You can do this:
section.visibleItemsInvalidationHandler = { [weak self] visibleItems, point, environment in
let indexPath = visibleItems.last!.indexPath
self?.pageControl.currentPage = indexPath.row
}
The collectionView delegate willDisplay method will tell you when a cell is added to the collectionView (e.g. is displayed on screen, as they are removed when they go offscreen).
That should let you know that panning has effectively occurred (and in most cases the important part is not the pan gesture or animation but how it affects the displayed content).
In that delegate method, collectionView.visibleCells can be used to determine what cells are displayed and from that one can derive the position.
I have found one convenient way to handle this issue, you can avoid setting orthogonal scrolling and use configuration instead this way:
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(sectionProvider:sectionProvider,configuration: config)
This will call all scroll delegates for collectionview. Hope this will be helpful for someone.
I need to do this app that has a weird configuration.
As shown in the next image, the main view is a UIScrollView. Then inside it should have a UIPageView, and each page of the PageView should have a UITableView.
I've done all this so far. But my problem is that I want the scrolling to behave naturally.
The next is what I mean naturally. Currently when I scroll on one of the UITableViews, it scrolls the tableview (not the scrollview). But I want it to scroll the ScrollView unless the scrollview cannot scroll cause it got to its top or bottom (In that case I'd like it to scroll the tableview).
For example, let's say my scrollview is currently scrolled to the top. Then I put my finger over the tableview (of the current page being shown) and start scrolling down. I this case, I want the scrollview to scroll (no the tableview). If I keep scrolling down my scrollview and it reaches the bottom, if I remove my finger from the display and put it back over the tebleview and scroll down again, I want my tableview to scroll down now because the scrollview reached its bottom and it's not able to keep scrolling.
Do you guys have any idea about how to implement this scrolling?
I'm REALLY lost with this. Any help will be greatly appreciate it :(
Thanks!
The solution to simultaneously handling the scroll view and the table view revolves around the UIScrollViewDelegate. Therefore, have your view controller conform to that protocol:
class ViewController: UIViewController, UIScrollViewDelegate {
I’ll represent the scroll view and table view as outlets:
#IBOutlet weak var scrollView: UIScrollView!
#IBOutlet weak var tableView: UITableView!
We’ll also need to track the height of the scroll view content as well as the screen height. You’ll see why later.
let screenHeight = UIScreen.mainScreen().bounds.height
let scrollViewContentHeight = 1200 as CGFloat
A little configuration is needed in viewDidLoad::
override func viewDidLoad() {
super.viewDidLoad()
scrollView.contentSize = CGSizeMake(scrollViewContentWidth, scrollViewContentHeight)
scrollView.delegate = self
tableView.delegate = self
scrollView.bounces = false
tableView.bounces = false
tableView.scrollEnabled = false
}
where I’ve turned off bouncing to keep things simple. The key settings are the delegates for the scroll view and the table view and having the table view scrolling being turned off at first.
These are necessary so that the scrollViewDidScroll: delegate method can handle reaching the bottom of the scroll view and reaching the top of the table view. Here is that method:
func scrollViewDidScroll(scrollView: UIScrollView) {
let yOffset = scrollView.contentOffset.y
if scrollView == self.scrollView {
if yOffset >= scrollViewContentHeight - screenHeight {
scrollView.scrollEnabled = false
tableView.scrollEnabled = true
}
}
if scrollView == self.tableView {
if yOffset <= 0 {
self.scrollView.scrollEnabled = true
self.tableView.scrollEnabled = false
}
}
}
What the delegate method is doing is detecting when the scroll view has reached its bottom. When that has happened the table view can be scrolled. It is also detecting when the table view reaches the top where the scroll view is re-enabled.
I created a GIF to demonstrate the results:
Modified Daniel's answer to make it more efficient and bug free.
#IBOutlet weak var scrollView: UIScrollView!
#IBOutlet weak var tableView: UITableView!
#IBOutlet weak var tableHeight: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
//Set table height to cover entire view
//if navigation bar is not translucent, reduce navigation bar height from view height
tableHeight.constant = self.view.frame.height-64
self.tableView.isScrollEnabled = false
//no need to write following if checked in storyboard
self.scrollView.bounces = false
self.tableView.bounces = true
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 20
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let label = UILabel(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 30))
label.text = "Section 1"
label.textAlignment = .center
label.backgroundColor = .yellow
return label
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = "Row: \(indexPath.row+1)"
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == self.scrollView {
tableView.isScrollEnabled = (self.scrollView.contentOffset.y >= 200)
}
if scrollView == self.tableView {
self.tableView.isScrollEnabled = (tableView.contentOffset.y > 0)
}
}
Complete project can be seen here:
https://gitlab.com/vineetks/TableScroll.git
After many trials and errors, this is what worked best for me. The solution has to solve two needs 1) determine who's scrolling property should be used; tableView or scrollView? 2) make sure that the tableView doesn't give authority to the scrollView until it has reached the top of it's table/content.
In order to see if the scrollview should be used for scrolling vs the tableview, i checked to see if the UIView right above my tableview was within frame. If the UIView is within frame, it's safe to say the scrollView should have authority to scroll. If the UIView is not within frame, that means that the tableView is taking up the entire window, and therefor should have authority to scroll.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.bounds.intersects(UIView.frame) == true {
//the UIView is within frame, use the UIScrollView's scrolling.
if tableView.contentOffset.y == 0 {
//tableViews content is at the top of the tableView.
tableView.isUserInteractionEnabled = false
tableView.resignFirstResponder()
print("using scrollView scroll")
} else {
//UIView is in frame, but the tableView still has more content to scroll before resigning its scrolling over to ScrollView.
tableView.isUserInteractionEnabled = true
scrollView.resignFirstResponder()
print("using tableView scroll")
}
} else {
//UIView is not in frame. Use tableViews scroll.
tableView.isUserInteractionEnabled = true
scrollView.resignFirstResponder()
print("using tableView scroll")
}
}
hope this helps someone!
None of the answers here worked perfectly for me. Each one had it's owned nuanced problem (needing to do a repeated swipe when one scrollview hit it's bottom, or the scroll indicator not looking correct, etc), so figured I'd throw in another answer.
Ole Begemann has a great write up on doing this exactly https://oleb.net/blog/2014/05/scrollviews-inside-scrollviews/
Despite being an old post, the concepts still apply to the current APIs. Additionally, there is a maintained (Xcode 9 compatible) Objective-C implementation of his approach https://github.com/eyeem/OLEContainerScrollView
If you are facing problem with the nested scrolling issue , here tis the simplest solution for it .
go to your design screen
select your scroll view and then disable bounce on scroll
if your view uses table view inside scroll view then disable bounce on scroll of the table view as well
run and check it is solved
check how to disable bounce on scroll of a scroll view
check how to disable bounce on scroll of a tableview view
I was struggling with this problem, too. There is a very simple solution.
In interface builder:
create simple ViewController
add a simple View, it will be our header, and constrain it to superview
it's the red view on the example below
I have added 12px from top, left and right, and set fixed height to 128px
embed a PageViewController, making sure it is constrained to the superview, and not the header
Now, here comes the fun part: for each page you add, make sure its tableView has an offset from top. Thats it. You can do if with this code, for example (assuming you use UITableViewController as a page):
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let tables = viewControllers.compactMap { $0 as? UITableViewController }
tables.forEach {
$0.tableView.contentInset = UIEdgeInsets(top: headerView.bounds.height, left: 0, bottom: 0, right: 0)
$0.tableView.contentOffset = CGPoint(x: 0, y: -headerView.bounds.height)
}
}
No messy scroll inside scroll inside table view, no mangling with delegates, no duplicated scrolls, perfectly natural behavior. If you can't see the header, it is probably because of the tableView background color. You have to set it to clear, for the header to be visible from under the tableView.
I think there are two options.
Since you know the size of the scroll view and the main view, you are unable to tell whether the scroll view hit the bottom or not.
if (scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.frame.size.height)) {
// reach bottom
}
So when it hit; you basically set
[contentScrollView setScrollEnabled:NO];
and other way around for your tableView.
The other thing, which is more precise I think, is to add Gesture to your views.
UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self action:#selector(respondToTapGesture:)];
// Specify that the gesture must be a single tap
tapRecognizer.numberOfTapsRequired = 1;
// Add the tap gesture recognizer to the view
[self.view addGestureRecognizer:tapRecognizer];
// Do any additional setup after loading the view, typically from a nib
So when you add Gesture, you can simply control the active view by changing setScrollEnabled in the respondToTapGesture.
I found an awesome library
MXParallaxHeader
In Storyboard just set UIScrollView class to MXScrollView then magic happens.
I used this class to handle my UIScrollView when I embed a UIPageViewController container view. even you can insert a parallax header view for more detail.
Also, this library provides Cocoapods and Carthage
I attached an image below which represent UIViewHierarchy.
MXScrollView Hierarchy
SWIFT 5
I had some trouble using Vineet's answer for when I could not guarantee the scrollView content offset (Y) due to various different screen sizes. To resolve this, I changed the first trigger event of when the tableView's scroll gets enabled.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.bounds.contains(button.frame) {
tableView.isScrollEnabled = true
}
if scrollView == tableView {
self.tableView.isScrollEnabled = (tableView.contentOffset.y > 0)
}
}
The scrollView.bounds.contains will check if a given element's frame is FULLY within the scrollView's visible content. I set this to a button that I have below the tableView. You could set this to your tableVIew's frame instead if your only condition is that your tableView is fully visible.
I left the original implementation of when to disable the tableView's scroll and it works very well.
I tried the solution marked as the correct answer, but it was not working properly. The user need to click two times on the table view for scroll and after that I was not able to scroll the entire screen again. So I just applied the following code in viewDidLoad():
tableView.addGestureRecognizer(UISwipeGestureRecognizer(target: self, action: #selector(tableViewSwiped)))
scrollView.addGestureRecognizer(UISwipeGestureRecognizer(target: self, action: #selector(scrollViewSwiped)))
And the code below is the implementation of the actions:
func tableViewSwiped(){
scrollView.isScrollEnabled = false
tableView.isScrollEnabled = true
}
func scrollViewSwiped(){
scrollView.isScrollEnabled = true
tableView.isScrollEnabled = false
}
One easy trick, if you want to achieve it is replacing parent scrollview with normal container view.
Adding a pan gesture on container view, you can play with top constraint of first view to assign negative values. You can keep a check of page View's origin if it achieves to top you can start assigning that value on content offset of the pageView's child view. Until user achieves the table view in a state of top most view in container view, you can keep page tableView's scrolling disabled and allow scrolling manually by setting content offset.
So initially the page view height will be collapsed (or say out of screen) or less at bottom. Later on scrolling down it will expand to take more space.
Gesture will automatically stop responding if out of frames say on nav bar or other view outside container view.
Gestures are a key to user interactive transitions used in many apps. You can mimic scroll for a certain time with it.
In my case I'm using constraint for height like that:
self.heightTableViewConstraint.constant = self.tableView.contentSize.height
self.scrollView.contentInset.bottom = self.tableView.contentSize.height
Below code works great for me
As I wanted to show some header after some scroll and table view supposed to scroll
And in ViewDidLoad add
override func viewDidLoad() {
super.viewDidLoad()
mainScrollView.delegate = self
}
Change 265 to whatever number you want to stop upper scroll
extension AccountViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print(notebookTableView.contentOffset.y)
if notebookTableView.contentOffset.y < 265 {
if notebookTableView.contentOffset.y > 0 {
mainScrollView.setContentOffset(notebookTableView.contentOffset, animated: false)
} else {
mainScrollView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: false)
}
} else {
mainScrollView.setContentOffset(CGPoint(x: 0.0, y: 265), animated: false)
}
}
}
CGFloat tableHeight = 0.0f;
YourArray =[response valueForKey:#"result"];
tableHeight = 0.0f;
for (int i = 0; i < [YourArray count]; i ++) {
tableHeight += [self tableView:self.aTableviewDoc heightForRowAtIndexPath:[NSIndexPath indexPathForRow:i inSection:0]];
}
self.aTableviewDoc.frame = CGRectMake(self.aTableviewDoc.frame.origin.x, self.aTableviewDoc.frame.origin.y, self.aTableviewDoc.frame.size.width, tableHeight);
Maybe brute-force, but working perfectly if cell heights are the same: by the way, I use auto layout.
for the tableView (or collectionView or whatever), set an arbitrary height in storyboard, and make an outlet to class. Wherever appropriate, (viewDidLoad() or...) set the tableView's height big enough so that tableView doesn't need to scroll. (need to know the number of rows in advance) Then only the outer scrollView will scroll nicely.
I need to drag a Cell from CollectionView One and drop it to
CollectionView Two.
The Drag & Drop inside one CollectionView is no Problem, but how
can i get the Cell out of CollectionView One to CollectionView Two?
Any ideas? Any projects or frameworks that have already solved this problem?
Thanks for helping!
https://github.com/Ice3SteveFortune/i3-dragndrop Check this out - its a helper I'm working on to achieve just that. It also supports tableviews
UPDATE
I've recently released the second version of this codebase, called BetweenKit. Its now a fully-fledged drag-and-drop framework.
Hope it proves useful !
When you select the cell from the first collection view, remove it from this collection view, create a new view as copy of that cell place it as subview of the superview on top of all views. Make that view movable using pan gestures. As soon as you "drop" this intermediary cell, detect its position and add it to the current collection view.
Ok, here is the simplest flow ever for the following example:
Add UIGestureRecognizer for every of UICollectionView.
Connect every gesture recognizer with one method:
#IBAction func longPressGestureChanged(recognizer: UILongPressGestureRecognizer) { ... }
Within UIViewController add #IBOutlet for each of UICollectionView:
#IBOutlet var collectionViewGreen: UICollectionView!
#IBOutlet var collectionViewYellow: UICollectionView!
Implement gesture recognizer method to detect changes:
#IBAction func longPressGestureChanged(recognizer: UILongPressGestureRecognizer) {
let globalLocation = recognizer.locationInView(view)
if CGRectContainsPoint(collectionViewGreen.frame, globalLocation) {
//you cover green collection view
let point = view.convertPoint(globalLocation, toView: collectionViewGreen)
if let indexPath = collectionViewGreen.indexPathForItemAtPoint(point) {
//you cover cell in green collection view
} else {
//you do not cover any of cells in green collection view
}
} else if CGRectContainsPoint(collectionViewYellow.frame, globalLocation) {
//you cover yellow collection view
let point = view.convertPoint(globalLocation, toView: collectionViewYellow)
if let indexPath = collectionViewYellow.indexPathForItemAtPoint(point) {
//you cover cell in yellow collection view
} else {
//you do not cover any of cells in yellow collection view
}
} else {
//you do not cover any of collection views
}
}