How would you go about implementing a UICollectionView with this behaviour?
The idea is that once a user navigates past a certain point, they cannot go back and view those cells again.
My attempt at a solution has been to listen for gestures on the collection view and if disable scrolling once a swipe occurs on the element. The obvious problem with this is that the user can simple hold and drag any particular cell.
Any thoughts?
I think this behavior may be confusing for your users.
Maybe you should try to add some elasticity/bouncing so that your users would be less confused.
Anyway, I see two different ways to achieve this without subclassing
1/ Since UICollectionViewDelegate conforms to UIScrollViewDelegate, you can get the starting offset of your scrollview with – scrollViewWillBeginDragging: then in – scrollViewDidScroll: you would compare the new offset's x value. If the new offset.x 's value is smaller than the starting one, set it to 0 and update your scrollview.
#pragma mark - UIScrollViewDelegate
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
offset = scrollView.contentOffset;
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGPoint newOffset = scrollView.contentOffset;
if (newOffset.x < offset.x) {
// scrolling to the left, reset offset
[scrollView setContentOffset:offset];
}
}
Because there is inertia with scrolling in iOS, scrollViewDidScroll: is called a lot of time, so it may cause performance issues. You may reduce the number of call by targeting your offset with scrollViewWillEndDragging:withVelocity:targetContentOffset: from UIScrollViewDelegate.
2/ Or ou can just use the method scrollViewWillEndDragging:withVelocity:targetContentOffset: which I just spoke about, which sets the offset back to its beginning, with an animation.
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
CGPoint newOffset = scrollView.contentOffset;
if (newOffset.x < offset.x) {
// scrolling to the left, reset offset with animation
targetContentOffset->x = offset.x;
}
}
3/ You spoke of UISwipeGestureRecognizer, did you give a try to UIPanGestureRecognizer? This is what "simple hold and drag" is.
You can implement it the same way you would implement an infinite scroll view, by adding / removing items based on the scroll offset.
Override the viewDidScroll: method (UICollectionViewDelegate)
Check if your offset puts the first object of your list past the offset (ie. can you still see it on the screen?)
If it is, then remove it from the collection view.
This simple implementation might result in choppy animations, you might have to do some optimization once it's working but this should get you started.
Another possible solution would be to "reposition" all your elements constantly to appear where they were before you started scrolling if you are scrolling left.
You can achieve this by keeping track of the highest offsetX you ever encountered, and reposition your cells if the current offsetX is lower than the max. That way you will have the impression that your cells are not moving or that you can't scroll, but you will actually be scrolling.
In my case, I have a paginated collection view, so I have to take care when decelerating in the last item of the collection view as well in automatic decelerations for each page.
To fix the issue, I just disable user interaction while the collection view is "moving automatically".
Here the code:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
if (!_isDragging) // <-- only do things if the user is dragging!
return;
CGPoint contentOffset = scrollView.contentOffset;
// If we move to the left
if (contentOffset.x < _contentOffset.x)
{
CGSize contentSize = scrollView.contentSize;
// If content offset is moving inside contentSize:
if ((contentOffset.x + scrollView.bounds.size.width) < contentSize.width)
scrollView.contentOffset= _contentOffset;
}
else
{
// Update the current content offset
_contentOffset = contentOffset;
}
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
_isDragging = YES;
}
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
_isDragging = NO;
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
if (decelerate)
{
// If willDecelerate, stop user interaction!
_collectionView.userInteractionEnabled = NO;
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
// Once deceleration is finished, enbale user interaction
_collectionView.userInteractionEnabled = YES;
// Set the new content offset
_contentOffset = scrollView.contentOffset;
}
Related
I want a tableview that starts offscreen and can scroll on-screen, reach the top, and keep scrolling. I've made a visual of the desired interaction below.
I've tried two things, and neither work exactly like I need.
The first thing I did was put the tableview in a scrollview, and move the scrollview when panning is detected on the tableview. This blocks touches in the tableview, and even if I could detect when the tableview hit the top of the screen, I'm not sure how I would continue scrolling.
The second thing I tried was to set the content size of the scrollview to the height of the tableview. This lets the tableview scroll, but I only seem to be able to receive touches in the initial small rectangle labeled "List Item 1". As the tableview scrolls, I can't grab the middle and scroll it anymore.
What's the best way to build this interaction? Edit: A map surrounds this bottom view to the left, right, and mostly top. When the bottom view is pulled up, the map is visible to the left and right.
1.)
2.)
3.) (and this keeps scrolling for as many items are as in the list.)
I guess you want something like this:
or this:
I laid out my table view over my map view. I set the table view's contentInset and contentOffset like this:
- (void)viewDidLoad {
[super viewDidLoad];
self.tableView.rowHeight = 44;
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
self.tableView.contentInset = (UIEdgeInsets){ .top = self.view.bounds.size.height - self.tableView.rowHeight };
self.tableView.contentOffset = CGPointMake(0, -self.tableView.contentInset.top);
}
Note that, although the default row height is 44, tableView.rowHeight return -1 unless you explicitly set it. (Setting it to 44 in the storyboard doesn't change this.)
I used a subclass of UITableView in which I did two things:
I explicitly set self.backgroundColor = [UIColor clearColor]. I found that setting the background color to clear in the storyboard didn't work.
I overrode pointInside:withEvent::
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return point.y >= 0 && [super pointInside:point withEvent:event];
}
Note that you don't care about contentInset here. The table view's contentOffset.y (which is the same as its bounds.origin.y) is set to a negative number when its top content inset is exposed. It's set to 0 when the top of item 0 is at the top edge of the table view, which isn't the case when the item as at the bottom edge of the screen.
Another thing you might want is to prevent the table from stopping half-on the screen. If the user drags item 0 halfway up the screen, you want the table to scroll so item 0 is all the way at the top of the screen (if there are sufficient items), and if the user drags item 0 halfway down the screen, you want the table to scroll so just item 0 is showing.
I did that by making my view controller act as the table view's delegate and implementing this delegate method, inherited from UIScrollViewDelegate:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
CGFloat yMin = -self.tableView.contentInset.top;
CGFloat yMax = MIN(0, self.tableView.contentSize.height - self.tableView.bounds.size.height);
if (targetContentOffset->y < yMax) {
if (velocity.y < 0) {
targetContentOffset->y = yMin;
} else {
targetContentOffset->y = yMax;
}
}
}
That method is carefully written so that it works for tables too short to fill the screen vertically, and for tables that can fill the screen vertically.
I've uploaded my test project here: https://github.com/mayoff/tableView-over-mapview
Update for side-by-side tables
I don't think side-by-side tables is going to be a good user interface. I think it's going to be confusing. But here's how you do it.
The view hierarchy looks like this:
Root view
MKMapView
MyScrollView
ScrollContentView
MyTableView for first table
MyTableView for second table
MyTableView for third table
etc.
The map view and the scroll view have the same frames. The scroll view handles the sideways scrolling and each table view is independently scrollable vertically.
Since the scroll view should only capture touches that land in one of the table views, it needs a custom hitTest:withEvent: that returns nil for touches outside any of the table views:
#implementation MyScrollView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitView = [super hitTest:point withEvent:event];
return hitView == self ? nil : hitView;
}
#end
But this won't actually do the job, because (in my implementation) the scroll view has just one big subview, the ScrollContentView. So we need to do the same thing in ScrollContentView:
#implementation ScrollContentView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitView = [super hitTest:point withEvent:event];
return hitView == self ? nil : hitView;
}
That's sufficient to pass touches down to the map view if they land outside of the tables.
I also use ScrollContentView to lay out the tables and set the scroll view's content size:
- (void)layoutSubviews {
[super layoutSubviews];
// Layout of subviews horizontally:
// [gutter/2][gutter][subview][gutter][subview][gutter][subview][gutter][gutter/2]
// where 3 * gutter + subview = width of superview
CGSize superSize = self.superview.bounds.size;
CGFloat x = kGutterWidth * 3 / 2;
CGFloat subWidth = superSize.width - kGutterWidth * 3;
for (UITableView *subview in self.subviews) {
subview.frame = CGRectMake(x, 0, subWidth, superSize.height);
x += subWidth + kGutterWidth;
CGFloat topInset = superSize.height - subview.rowHeight;
subview.contentInset = (UIEdgeInsets){ .top = topInset };
subview.contentOffset = CGPointMake(0, -topInset);
}
x += kGutterWidth / 2;
self.frame = CGRectMake(0, 0, x, superSize.height);
((UIScrollView *)self.superview).contentSize = self.bounds.size;
_pageWidth = subWidth + kGutterWidth;
}
I also made my view controller be the scroll view's delegate, and implemented a delegate method to force the scroll view to stop on “page” (table) boundaries:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
CGFloat pageWidth = contentView.pageWidth;
// Force scroll view to stop on a page boundary.
CGFloat pageNumber = targetContentOffset->x / pageWidth;
if (velocity.x < 0) {
pageNumber = floor(pageNumber);
} else {
pageNumber = ceil(pageNumber);
}
pageNumber = MAX(0, MIN(pageNumber, contentView.subviews.count - 1));
targetContentOffset->x = pageNumber * pageWidth;
}
The result:
I've updated the git repository with this version.
You ought to be able to do this pretty easily by setting your table view’s top contentInset to something high (as sha suggested in the comments) and then making your UITableView a subclass so you can override -pointInside:withEvent:. Using that and the current contentOffset, you can determine whether the incoming event is inside the area you want to be scrollable, and return YES or NO accordingly; if you return NO, then the touch should fall through to the map view as intended.
Why not change this completely. You said you have a map "underneath" the tableview. So when scrolled up the map will be hidden over by the table view. I presume when you scroll down again the map will be revealed?
You should be able to do this by using the UITableView header. Either a section header or a table view header. They behave slightly differently upon scrolling.
I'd maybe do it this way...
Use a table view header on the table. In this header you place your map view.
By default this will be pinned to the top of the table so if you scroll the table up then the map will slide off the top of the screen with it.
However, if you then intercept the scroll view delegate method - (void)scrollViewDidScroll:(UIScrollView *)scrollView; then you can work out if the table is being scrolled upwards and offset the map view so it stays where it is.
i.e. if the table is scrolled to (0, 10) then offset the map to (0, -10) so it looks like it hasn't moved.
This will give you the scroll in and out feature of the tableview and keep the map in view and responding to touches.
Example:
You have a UIScrollView with scrollEnabled = NO.
After user pans a certain distance, you want the scroll view to start scrolling.
When finger goes down on screen and I disable scrolling, the scroll view is dead until the finger is lifted. Enabling scrolling in middle of a pan doesn't cause it to start.
I suspect it is because UIScrollView will decide to ignore the touch sequence if it was scrollEnabled=NO during its internal touchesBegan:withEvent: (or its private pan gesture recognizer).
How would you trick it into starting to scroll natively (with bounce effect) in the middle of a touch sequence?
Reason: For a simple UIKit based game that requires this mechanic.
Okay, this still needs a little bit of tweaking to handle scrolling again after the first scroll has stopped, but I think the key to what you're trying to achieve is to implement the UIScrollViewDelegate method scrollViewDidScroll:. From here, you can save the starting content offset of the scroll view, and compare the scroll view's current offset to the starting offset added to the y translation of the scroll view's pan gesture recognizer.
The code below successfully doesn't allow scrolling to start until the user has panned at least 100 pts. Like I said, this needs a little tweaking, but should be enough to get you going.
- (void)viewDidLoad
{
[super viewDidLoad];
[self setStartingOffset:CGPointMake(0.0, -1.0)];
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
if (self.startingOffset.y < 0.0) {
[self setStartingOffset:scrollView.contentOffset];
}else{
static CGFloat min = 100.0;
CGFloat yTranslation = fabs([scrollView.panGestureRecognizer translationInView:scrollView].y);
if (yTranslation < self.startingOffset.y + min) {
[scrollView setContentOffset:self.startingOffset];
}else{
if (self.startingOffset.y >= 0.0) {
[self setStartingOffset:CGPointMake(0.0, -1.0)];
}
}
}
}
I am working with aUIScrollview, and based on if the user scrolls left or right, I reconfigure the UI. The problem is that I need to verify that the user definitely crossed from one screen to another (something along contentOffset).
I've tried using this method:
-(void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
But this will fire if the user kind of moves the scrollview partially in one direction, but then doesn't complete the gesture.
I've also tried this method:
-(void)scrollViewDidScroll:(UIScrollView *)scrollView;
but mainly the same issue; the scrollview scrolls left and right, but on an iPhone, with a content with of 640 ( 320 * 2). I am trying to figure out if the scroll did cross over or not, from one location to the other.
Any suggestions?
I am not entirely sure what you mean by "crossed from one screen to another" I assume you mean paging? If so, here's what you can do:
- (void)scrollViewDidEndDecelerating:(UIScrollView *)sender {
int page = sender.contentOffset.x / width;
[self.pageControl setCurrentPage:page]; // in case you need it for updating a UIPageControl
}
I found a way of doing this :
I have a variable to maintain the x location of the contentOffset
-(void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
currX = self.scrollView.contentOffset.x;
}
And then in this event :
-(void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
if(self.scrollView.contentOffset.x == currX ) {
return;//This is what I needed, If I haven't panned to another view return
}
//Here I do my thing.
im atempting to do a facebook type load more data and right now it works, but very laggy on the device because its asking the server to get anything that isnt there already, then calling a scroll all the way down function (because somehow when i reload the data it scrolls to the top). If there would be a way to prevent scrolling to the top that would be great. But my main thing is, is there a way to detect when i scrolled down, and LET GO (stopped scrolling) as in i scrolled past what i have, then it went back to its possition and then calls my methods... Currently it keeps getting called when the scroll is greater then the height of the UITableView heres the code
-(void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGFloat height = scrollView.frame.size.height;
CGFloat contentYoffset = scrollView.contentOffset.y;
CGFloat distanceFromBottom = scrollView.contentSize.height - contentYoffset;
if(distanceFromBottom < height)
{
[getMessage removeAllObjects];
[self loadMessages];
[self.tableView reloadData];
[self scrollAllTheWayDown];
}
}
UIScrollViewDelegate method
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
does what you want. it gets called after user stops scrolling, and you can check for scrollOffset at that point to see if you should trigger your refresh code. (you'd use scrollViewDidScroll to update the view to show user update will happen if he lets go)
I want to revert my UIScrollView's content offset if I don't drag enough:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(CGPoint *)targetContentOffset {
self.endingOffset = scrollView.contentOffset;
if(abs(verticalOffset) > [self cellHeight] / 9) { // If the user scrolled enough distance, attempt to scroll to the next cell
...
} else if(self.nextCellOrigin.y != 0) { // The scroll view is still scrolling and the user didn't drag enough
...
} else { // If the user didn't drag enough
self.tableView.decelerationRate = UIScrollViewDecelerationRateNormal;
(*targetContentOffset) = self.startingOffset;
}
}
The code to revert to the original position is in the else portion, and it always works. However, when I don't scroll enough and make the gesture quickly, it snaps back. If I scroll just a little and then hold that position for slightly longer than usual, it reverts back smoothly.
I haven't found anything in the API reference for how long a user has touched a UIScrollView, and even if I did it's not immediately obvious how I could use that to change the behavior of my reverting code. I've also tried scrolling to the position with setContentOffset:animated: but that doesn't seem to fix the jerkiness.
Any ideas?
Have you tried logging the velocity to find out how it is when the jerkiness happens?
EDIT:
What you can try to do is implement these two scrollView delegate methods instead of the willEndDragging method. This solution will give a different feeling to the scrollView, but give it a try.
Fill the checkOffset method with all the logic you need.
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
// if scrollView's contentOffset reached its final position during scroll, it will not decelerate, so you need a check here too
if (!decelerate) {
[self checkOffset];
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self checkOffset];
}
- (void)checkOffset {
CGPoint newOffset;
...
// Do all the logic you need to move the content offset, then:
...
[self.scrollView setContentOffset:newOffset animated:YES];
}
EDIT #2:
Maybe you could achieve a better result if you also add this to my solution.. Try ;)
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(CGPoint *)targetContentOffset {
// This should force the scrollView to stop its inertial deceleration, by forcing it to stop at the current content offset
*targetContentOffset = scrollView.contentOffset;
}