0
votes

I've been trying for quite awhile to build a parallax-style table view header that's comprised of an image, similar to the Yahoo News Digest App, or when viewing a business in Maps.app. (When you rubber-band the table the image height grows, and when scrolling down the image appears to scroll slightly slower).

Here's an demonstrative video courtesy of APParallaxHeader:

https://www.youtube.com/watch?v=7-JMdapWXGU

The best tutorial I was able to find was this tutorial, which basically consists of adding the image view as a subview of the table view. While that mostly works, adding as a subview to UITableView is pretty undocumented, and in my testing does not appear to work with Auto Layout and thus rotation doesn't play nicely.

The library I linked above, APParallaxHeader, seems to work, but it's implementation is really confusing, and seems to be swizzling if I'm not wrong?

Is there a simple way to do this that I'm just completely overlooking?

5
I seems to me, you could do that with a table header view. You would need to adjust its height on scrolling, and stretch the image view (modify its constraints, or apply a transform) inside it as well.rdelmar
@rdelmar Are you sure? How would you create the rubber banding height effect when the table view itself is pulled down?Doug Smith
@DougSmith the controlling parameter for the height and zoom amount is the table's contentOffset.y. This value changes in very close correspondence with the bounce. Sounds like a fun exercise. (and I agree with you that it seems like it ought to be pretty simple).danh

5 Answers

4
votes

After giving this problem some more thought, I think the best way to duplicate that look is with a scrollview containing an image view that's behind (in the z-order sense) and extending below (in the y-direction sense) the top of a table view. In the test I did, I gave the table view a header (in IB) that was 100 points tall, and with a clear background color (the table also needs a clear background color). The scroll view and the table view were both pinned to the sides of the controller's main view, and to the top layout guide (the controller is embedded in a navigation controller, that was set to have its view not go under the top bar). The table view was also pinned to the bottom of the view, and the scroll view was given a fixed height of 200. I gave the scroll view an initial offset of 50 points, so that when you start to pull down on the table, the scroll view can scroll more content into view from the top, while also revealing more content at the bottom (the scroll view's offset is moving at 1/2 the rate of the table view's offset). Once the table view's offset reaches -50, I stop changing the scroll view's offset, and start zooming.

#define ZOOMPOINT 50

@interface ViewController () <UITableViewDataSource, UITableViewDelegate, UIScrollViewDelegate>
@property (weak, nonatomic) IBOutlet UIScrollView *sv;
@property(weak,nonatomic) IBOutlet UITableView *tableView;
@property (strong,nonatomic) UIImageView *iv;
@end

@implementation ViewController

-(void)viewDidLoad {
    [super viewDidLoad];
    self.sv.minimumZoomScale = 1.0;
    self.sv.maximumZoomScale = 2.0;
    self.sv.delegate = self;
    self.iv = [UIImageView new];
    self.iv.contentMode = UIViewContentModeScaleAspectFill;
    self.iv.translatesAutoresizingMaskIntoConstraints = NO;

}


-(void)viewDidLayoutSubviews {
    [self.iv removeFromSuperview];
    [self.sv addSubview:self.iv];
    [self.sv addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|[iv(==width)]|" options:0 metrics:@{@"width":@(self.tableView.frame.size.width)} views:@{@"iv":self.iv}]];
    [self.sv addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[iv(==250)]|" options:0 metrics:nil views:@{@"iv":self.iv}]];
    self.iv.image = [UIImage imageNamed:@"img.jpg"]; // the image I was using was 500 x 500
    self.sv.contentOffset = CGPointMake(0, ZOOMPOINT);
}

- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
    if ([scrollView isEqual:self.sv]) {
        return self.iv;
    }else{
        return nil;
    }
}


- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale {

}


- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if (scrollView != self.sv) {
        if (scrollView.contentOffset.y < -ZOOMPOINT) {
            [self.sv setZoomScale:(scrollView.contentOffset.y + ZOOMPOINT)/-100 + 1]; // the -100 is arbitrary, change to affect the sensitivity of the zooming
        }else{
             self.sv.contentOffset = CGPointMake(0, ZOOMPOINT + scrollView.contentOffset.y/2.0);
        }
    }
}



- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 20;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
    cell.textLabel.text = [NSString stringWithFormat:@"Cell %ld", (long)indexPath.row];
    return cell;
}

I've uploaded a copy of this project here, http://jmp.sh/LRKF0nM

1
votes

I thought I'd throw out another idea that doesn't use a separate scroll view. I think this works a little better with the way it expands. So, in this attempt, I just add the image view as a subview of the main view, and placed it so 1/2 as much of the image view is above the top of the header (out of view) as below the header (initially hidden by the table rows). When pulling down the table, the view is moved down at half the rate of the pull down (by adjusting a constraint), so the top and the bottom of the image come into view together, then from there, I do the expansion by using a transform.

#import "ViewController.h"
#define ZOOMPOINT -60

@interface ViewController () <UITableViewDataSource, UITableViewDelegate, UIScrollViewDelegate>
@property(weak,nonatomic) IBOutlet UITableView *tableView;
@property (weak, nonatomic) IBOutlet UIView *tableHeader;

@property (strong,nonatomic) UIImageView *iv;
@property (strong,nonatomic) NSLayoutConstraint *topCon;
@end

@implementation ViewController

-(void)viewDidLoad {
    [super viewDidLoad];
    self.iv = [UIImageView new];
    self.iv.contentMode = UIViewContentModeScaleToFill; //UIViewContentModeScaleAspectFill;
    self.iv.translatesAutoresizingMaskIntoConstraints = NO;
    self.edgesForExtendedLayout = UIRectEdgeNone;
}


-(void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    [self.view addSubview:self.iv];
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|[iv]|" options:0 metrics:nil views:@{@"iv":self.iv}]];
    self.topCon = [NSLayoutConstraint constraintWithItem:self.iv attribute:NSLayoutAttributeTop relatedBy:0 toItem:self.view attribute:NSLayoutAttributeTop multiplier:1 constant:ZOOMPOINT/2.0];
    [self.iv addConstraint:[NSLayoutConstraint constraintWithItem:self.iv attribute:NSLayoutAttributeHeight relatedBy:0 toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:self.tableHeader.frame.size.height - ZOOMPOINT*1.5]];
    [self.view addConstraint:self.topCon];
    [self.view layoutIfNeeded];

    self.iv.image = [UIImage imageNamed:@"img.jpg"];
    [self.view sendSubviewToBack:self.iv];
}



- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if (scrollView.contentOffset.y < 0 && scrollView.contentOffset.y > ZOOMPOINT) {
        self.topCon.constant = ZOOMPOINT/2.0 - scrollView.contentOffset.y/2.0;
    }else if (scrollView.contentOffset.y <= ZOOMPOINT) {
        self.iv.transform = CGAffineTransformMakeScale(1 - (scrollView.contentOffset.y - ZOOMPOINT)/200, 1 - (scrollView.contentOffset.y - ZOOMPOINT)/200);
    }

}


- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 20;
}



- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
    cell.textLabel.text = [NSString stringWithFormat:@"Cell %ld", (long)indexPath.row];
    return cell;
}

The project can be found here, http://jmp.sh/7PXzISZ

0
votes

Just riffing here, but if the header's natural frame is frame, and you've got the table's scroll view delegate set, then the zoomed frame would be very similar to:

// in scrollViewDidScroll:
// when the table view is scrolled beyond the header, contentOffset.y is negative
CGFloat headerAspect = frame.size.width / frame.size.height;
CGFloat offsetY = tableView.contentOffset.y;
CGFloat offsetX = offsetY * headerAspect;

// this will enlarge frame since offsets are < 0
frame = CGInsetRect(frame, offsetY, offsetX);

// slide it down to keep the top at the top of the header
frame = CGRectOffset(frame, 0, offsetY / 2.0);

Couple this with setting the contentMode on the image view to UIViewContentModeScaleToFill, and that should be a decent start.

0
votes

Okay, here's an answer that has the benefit of getting built and tried out.

I found it too hard to manipulate the frame of the table's actual header view, so I added a subview to the table above the rows. In order for that view to show up as a regular table header, I gave the table a fixed sized, transparently colored header view.

The main idea is like what I answered above: using the table's content offset as the parameter for modifying the image view frame, and the imageView's content mode (corrected to UIViewContentModeScaleAspectFill) to provide the zooming effect as the frame changes.

Here's the whole view controller. This is built from a storyboard where the view controller is inside a navigation controller. It has nothing more than a table view filling its view, with the datasource and delegate set.

#import "ViewController.h"

// how much of the image to show when the table is un-scrolled
#define HEADER_HEIGHT (100.0)

// the height of the image scaled down to fit in the header.  the real image can/should be taller than this
// i tested this with a 600x400 image
#define SCALED_IMAGE_HEIGHT (200.0)

// zoom image up to this offset
#define MAX_ZOOM (150.0)

@interface ViewController () <UITableViewDataSource, UITableViewDelegate>

@property(weak,nonatomic) IBOutlet UITableView *tableView;

@end

@implementation ViewController

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];

    // build the header in view will appear after other layout constraints are applied
    UIImageView *headerView = (UIImageView *)[self.tableView viewWithTag:99];
    if (!headerView) {
        headerView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"landscape.png"]];
        headerView.tag = 99;
        headerView.contentMode = UIViewContentModeScaleAspectFill;
        headerView.clipsToBounds = YES;

        headerView.frame = CGRectMake(0, HEADER_HEIGHT, self.view.bounds.size.width, SCALED_IMAGE_HEIGHT);
        [self.tableView addSubview:headerView];
    }
}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {

    CGFloat offsetY = -self.tableView.contentOffset.y - 64;
    // minus 64 is kind of a bummer here.  this calc wants the offset to be 0
    // when no scrolling has happened.  for some reason my table view starts at -64

    CGFloat clamped = MIN(MAX(offsetY, 0), MAX_ZOOM);
    CGFloat origin = -HEADER_HEIGHT - clamped;
    CGFloat height = SCALED_IMAGE_HEIGHT + clamped;

    UIImageView *headerView = (UIImageView *)[self.tableView viewWithTag:99];

    CGRect frame = headerView.frame;
    frame.origin.y = origin;
    frame.size.height = height;
    headerView.frame = frame;
}

// this is a trick to make the view above the header visible: make the table header a clear UIView
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
    UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.bounds.size.width, HEADER_HEIGHT)];
    view.backgroundColor = [UIColor clearColor];
    return view;
}

- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
    return HEADER_HEIGHT;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 30;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
    cell.textLabel.text = [NSString stringWithFormat:@"Cell %ld", indexPath.row];
    return cell;
}

@end