2

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.)
1

2.)
2

3.) (and this keeps scrolling for as many items are as in the list.)
3

Stefan Kendall
  • 66,414
  • 68
  • 253
  • 406
  • Why don't you change contentInset to something big (size of screen) at the top? This way even if your tableView will occupy the whole screen - list items will only start at the very bottom. – sha Aug 25 '14 at 13:44
  • @sha would that not block touches to the thing behind the scrollview? The view not shown needs to be interactable with pan/zoom/tap while the list is moving up and while it is closed. – Stefan Kendall Aug 25 '14 at 13:50
  • It might. You might need to do something special to pass gestures down to the scroll view underneath. But if you need to just update something there as user scrolls tableview up - you don't even have to do that - just configure and change background image properly. – sha Aug 25 '14 at 14:20
  • The list can stop halfway, and the thing behind it is a map that needs to be fully interactable. – Stefan Kendall Aug 25 '14 at 14:24

3 Answers3

20

I guess you want something like this:

small list

or this:

big list

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:

  1. I explicitly set self.backgroundColor = [UIColor clearColor]. I found that setting the background color to clear in the storyboard didn't work.

  2. 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:

multiple tables side-by-side

I've updated the git repository with this version.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • 2
    This is a helluvan answer. I'll try this out when I get a chance. Love the images. – Stefan Kendall Aug 27 '14 at 15:11
  • I've updated my answer to avoid letting the table view stop when it's only half on the screen. – rob mayoff Aug 27 '14 at 16:34
  • I'm having one last bit of trouble. I have many tableviews, generated dynamically, which are in a scrollview. I can either have map interaction, or I can page left and right and get correct scrolling like you've demoed. I've tracked it down the the tableview subclass implementation of pointInside. point.y is always greater than 0, and [super pointInside] always returns false. Each tableview's origin's x is offset by a screen width, but no matter how I fiddle with this I'm stuck on how to fix the pointInside method. – Stefan Kendall Aug 28 '14 at 13:58
  • CGPoint pointInSubview = [view convertPoint:point fromView:self]; That's the money shot. – Stefan Kendall Aug 28 '14 at 14:26
  • I've updated my answer and test project for multiple tables side-by-side. – rob mayoff Aug 28 '14 at 17:08
  • The updated hitTest methods make this work with a containing view or adding the tableviews to the scrollview directly. – Stefan Kendall Aug 28 '14 at 18:02
2

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.

Noah Witherspoon
  • 57,021
  • 16
  • 130
  • 131
1

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.

Fogmeister
  • 76,236
  • 42
  • 207
  • 306
  • What I haven't made clear in the image is that the map actually appears to the left and right of the bottommost cell before it is pulled up, and when it is pulled up. I'll update the question. – Stefan Kendall Aug 27 '14 at 14:17
  • @StefanKendall you could still do the same thing. Will still work the same way (if I understand what you mean). – Fogmeister Aug 27 '14 at 14:23
  • @StefanKendall perhaps a couple of more detailed screenshots of what you're trying to achieve will help? – Fogmeister Aug 27 '14 at 14:30