UINavigationBar that Scrolls Away (Revised)

On this blog one of my most searched for posts is the one about a UINavigationBar that scrolls away.

That was written a while back.  I’ve recently had to revisit this topic again, so I thought I’d revise my implementation, which aims to remove many caveats and just make it easy to drop into your code and you’re done.

So I redid that code and made it slightly less hacky, and implemented it as a category on UIVIewController. If it doesn’t work for you the only thing I can think of are the new properties on UIViewController that pertain to layout. Mine had automaticallyAdjustScrollViewInsets to YES, and extend edges under top bars.

//  UIViewController+ScrollyNavBar.h
//
//  Created by Stephen O'Connor on 24/03/16.
//  MIT License
//

/*
 
 How to use this:
 
 import this into your (probably) table view controller
 
 make sure you set HS_navigationBarLayerDefaultPosition in viewDidLoad:
 
 self.HS_navigationBarLayerDefaultPosition = self.navigationController.navigationBar.layer.position;
 
 optionally set HS_scrollingNavigationBarThresholdHeight if you want to be able to scroll a bit before 
 the nav bar starts scrolling with it.  A typical use case would be if you want the bar to start
 scrolling with your first table view section, and not with the tableViewHeader.
 
 in viewWillAppear:, call:
 
 - (void)HS_updateNavigationBarPositionUsingScrollView:(UIScrollView*)scroller
 
 and, assuming your table view controller is still the UITableView's delegate, it's also
 a scroll view delegate.  In -scrollViewDidScroll:, call:
 
 - (void)HS_updateNavigationBarPositionUsingScrollView:(UIScrollView*)scroller
 
 as well.
 
 Done!
 
 
 */


#import <UIKit/UIKit.h>

@interface UIViewController (ScrollyNavBar)

@property (nonatomic, assign) CGFloat HS_scrollingNavigationBarThresholdHeight;  // defaults to 0.  I.e. think about tableViewHeader's height
@property (nonatomic, assign) CGPoint HS_navigationBarLayerDefaultPosition;

- (void)HS_updateNavigationBarPositionUsingScrollView:(UIScrollView*)scroller;

@end

Then then .m file:

// some theoretical knowledge here:  http://nshipster.com/associated-objects/

#import "UIViewController+ScrollyNavBar.h"
#import <objc/runtime.h>


@implementation UIViewController (ScrollyNavBar)

@dynamic HS_scrollingNavigationBarThresholdHeight;
@dynamic HS_navigationBarLayerDefaultPosition;

- (void)setHS_navigationBarLayerDefaultPosition:(CGPoint)HS_navigationBarLayerDefaultPosition {
    objc_setAssociatedObject(self,
                             @selector(HS_navigationBarLayerDefaultPosition),
                             [NSValue valueWithCGPoint:HS_navigationBarLayerDefaultPosition],
                             OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (CGPoint)HS_navigationBarLayerDefaultPosition {
    return [(NSValue*)objc_getAssociatedObject(self, @selector(HS_navigationBarLayerDefaultPosition)) CGPointValue];
}

- (void)setHS_scrollingNavigationBarThresholdHeight:(CGFloat)HS_scrollingNavigationBarThresholdHeight {
    objc_setAssociatedObject(self,
                             @selector(HS_scrollingNavigationBarThresholdHeight),
                             @(HS_scrollingNavigationBarThresholdHeight),
                             OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (CGFloat)HS_scrollingNavigationBarThresholdHeight {
    return [(NSNumber*)objc_getAssociatedObject(self, @selector(HS_scrollingNavigationBarThresholdHeight)) floatValue];
}

- (void)HS_updateNavigationBarPositionUsingScrollView:(UIScrollView*)scroller
{
    // get the navigation bar's underlying CALayer object
    CALayer *layer = self.navigationController.navigationBar.layer;
    CGFloat contentOffsetY = scroller.contentOffset.y;
    CGPoint defaultBarPosition = self.HS_navigationBarLayerDefaultPosition;
    CGFloat scrollingThresholdHeight = self.HS_scrollingNavigationBarThresholdHeight;
    
    // if the scrolling is not at the top and has passed the threshold, then set the navigationBar layer's position accordingly.
    if (contentOffsetY > -scroller.contentInset.top + scrollingThresholdHeight) {
        
        CGPoint newPos;
        newPos.x = layer.position.x;
        newPos.y = defaultBarPosition.y;
        newPos.y = newPos.y - MIN(contentOffsetY + scroller.contentInset.top - scrollingThresholdHeight, scroller.contentInset.top);
        
        layer.position = newPos;
        
    }
    else
    {
        layer.position = defaultBarPosition;  // otherwise we are at the top and the navigation bar should be seen as if it can't scroll away.
    }
}

@end

Boom! Pretty straightforward.

Note however, that I don’t really recommend this functionality because it’s prone to error, and it’s sort of hacking UINavigationBar.  It has a few related consequences:

  • UITableView section headers will still ‘stick’ to the bottom of the now invisible navigation bar.
  • You must use a translucent UINavigationBar for this to really work properly.

Now, for the first of those two points, I don’t have a solution and anyone who does, please leave me something in the comments.

For the second one, if you read the API docs of UINavigationBar closely, you’ll see:

@property(nonatomic, assign, getter=isTranslucent) BOOL translucent

Discussion

The default value is YES. If the navigation bar has a custom background image, the default is YES if any pixel of the image has an alpha value of less than 1.0, and NO otherwise.

So, with a little trickery, we can just change the Class on a UINavigationController to use a custom UINavigationBar class that sets an *almost* opaque image as the background image any time you change the barTintColor:


#import "UIImage+NotQuiteOpaque.h"

@interface HSNavigationBar : UINavigationBar
@end

@implementation HSNavigationBar

- (void)awakeFromNib
{
    [self setBarTintColor:self.barTintColor];
}

- (void)setTranslucent:(BOOL)translucent
{
    NSLog(@"This HSNavigationBar must remain translucent!");
    [super setTranslucent:YES];
}

- (void)setBarTintColor:(UIColor *)barTintColor
{
    UIImage *bgImage = [UIImage HS_stretchableImageWithColor:barTintColor];
    [self setBackgroundImage:bgImage forBarMetrics:UIBarMetricsDefault];
}

@end

And you’ll see here that the real trick is this category on UIImage that generates a stretchable image of the color specified, but it sets the top right corner’s pixel alpha value to 0.99. I assume that nobody will notice that slight translucency in one pixel at the top right of the screen.

@implementation UIImage (NotQuiteOpaque)

+ (UIImage*)HS_stretchableImageWithColor:(UIColor *)color
{
    
    UIImage *result;
    
    CGSize size = {5,5};
    CGFloat scale = 1;
    
    CGFloat width = size.width * scale;
    CGFloat height = size.height * scale;
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    
    size_t bitsPerComponent = 8;
    size_t bytesPerPixel    = 4;
    size_t bytesPerRow      = (width * bitsPerComponent * bytesPerPixel + 7) / 8;
    size_t dataSize         = bytesPerRow * height;
    
    unsigned char *data = malloc(dataSize);
    memset(data, 0, dataSize);
    
    CGContextRef context = CGBitmapContextCreate(data, width, height,
                                                 bitsPerComponent,
                                                 bytesPerRow, colorSpace,
                                                 (CGBitmapInfo)kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
    
    CGFloat r, g, b, a;
    UIColor *colorAtPixel = color;
    
    r = 0, g = 0, b = 0, a = 0;
    
    for (int x = 0; x < (int)width; x++)
    {
        for (int y = 0; y < (int)height; y++)
        {
            if (x == (int)width - 1 && y == 0) {
                colorAtPixel = [color colorWithAlphaComponent:0.99f];  // top right
            }
            
            [colorAtPixel getRed:&r green:&g blue:&b alpha:&a];
            
            
            int byteIndex = (int)((bytesPerRow * y) + x * bytesPerPixel);
            data[byteIndex + 0] = (int)roundf(r * 255);    // R
            data[byteIndex + 1] = (int)roundf(g * 255);  // G
            data[byteIndex + 2] = (int)roundf(b * 255);  // B
            data[byteIndex + 3] = (int)roundf(a * 255);  // A
            
            colorAtPixel = color;
        }
    }
    
    CGColorSpaceRelease(colorSpace);
    CGImageRef imageRef = CGBitmapContextCreateImage(context);
    result = [UIImage imageWithCGImage:imageRef scale:scale orientation:UIImageOrientationUp];
    CGImageRelease(imageRef);
    CGContextRelease(context);
    free(data);

    result = [result resizableImageWithCapInsets:UIEdgeInsetsMake(2, 2, 2, 2) resizingMode:UIImageResizingModeStretch];
    
    return result;
    
}

@end

So, there it is. It works, yes, but not perfectly, and the whole thing feels a bit hacky. I wonder if there’s a better way to do this, or if in the future Apple will start to update this code. Seeing as we see collapsing address bars on WebView controllers like in Safari, perhaps they might abstract this further to support UINavigationControllers.

How to know when a UIScrollView is scrolling (moving).

I don’t know about you, but it’s happened to me where I’ve needed to know when a scroll view is currently scrolling, and ONLY when it is scrolling. This means, when it is visually moving. You see, there are delegate methods such as scrollViewDidScroll: but these get called if the contentOffset changes. This doesn’t mean you have observed a visually moving scrollView.

I guess the most obvious use case is when you are hacking a scrollView to allow for “infinite scrolling”. There are many approaches to take when doing such work, but inevitably you’re most likely going to be trying to shift your subviews to different locations and then programmatically reset your contentOffset to contentOffset + contentWidth or some multiple of a constant that’s related to your content, or something like that.

With me so far? All I want is a scrollView that has the following

// KVO Observable
@property (nonatomic, readonly, getter = isScrolling) BOOL scrolling;  

(If you are unfamiliar with KVO, I would say Apple has the fundamental documentation, but if you’re a bit of a blacksmith like me, such documents, although incredibly exact, also put me to sleep. So, I recommend you just google “KVO Objective-C Tutorial” and see what you find.)

So here it is. The problem is, you need to use the scrollView delegate to make it work, but you don’t want to steal the delegate callbacks away from someone who really needs them, so in order to do this, you have to sort of ‘re-route’ the delegate callbacks. (If you’re implementing an infinite scroller, chances are you need to intercept / re-route the delegate anyway because I imagine you’ll create your own dataSource and delegate that are something like (note the conformance to the UIScrollViewDelegate):

@protocol HSCircularScrollViewDelegate<UIScrollViewDelegate>

@optional
- (void)circularScrollView:(HSCircularScrollView*)csv didMoveToItemAtIndex:(NSUInteger)itemIndex;
- (void)circularScrollView:(HSCircularScrollView*)csv didTapOnItem:(UIView*)item atIndex:(NSUInteger)itemIndex;

@end

@protocol HSCircularScrollViewDataSource 

@required
- (NSUInteger)numberOfItemsInCircularView:(HSCircularScrollView*)csv;
- (UIView*)circularScrollView:(HSCircularScrollView*)csv viewForItemAtIndex:(NSUInteger)itemIndex;
@end

But I digress…

Step 1 : Intercept the delegate

In your .m file’s Private Interface (Encapsulate!):

@interface HSCircularScrollView()<UIScrollViewDelegate>  // conform to the delegate
{
    __weak id<UIScrollViewDelegate> _myDelegate;  // the delegate that other calling classes will set.

    BOOL _isSettingContentOffset;  // We will need this later!
}

/* 
   here we make scrolling have a public getter and a private setter.  
   The accessors are automatically synthesized, which is GOOD, because 
   these auto-synthesized methods generate KVO notifications whenever 
   we use the dot notation. i.e. self.scrolling = YES; 
   to change them.  Free functionality!  
*/
@property (nonatomic, assign, getter = isScrolling) BOOL scrolling;  
@end

I also hope to teach a bit of best practices, so please get in the habit of making your views be able to use Interface Builder by supporting the NSCoding protocol:

@implementation HSCircularScrollView 

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self setupDefaults];
    }
    return self;
}

- (id)initWithCoder:(NSCoder *)aDecoder
{
    self = [super initWithCoder: aDecoder];
    if (self) {
        [self setupDefaults];
    }
    return self;
}
- (void)setupDefaults
{
    _scrolling = NO; 
    [super setDelegate: self];  // this is where we hijack the delegate.  see the overridden method setDelegate:
}

- (id<UIScrollViewDelegate>)delegate
{
    return _myDelegate;
}

- (void)setDelegate:(id<UIScrollViewDelegate>)aDelegate
{
    //  We are the delegate.  We will always be the delegate.  
    //  We use our delegate callbacks to pass the message along 
    //  to the argument provided to this method
    [super setDelegate:self];  
    
    if (aDelegate != _myDelegate)
    {
        _myDelegate = aDelegate;
    }
}

// ... continued in next section

I have to stop midway here to make a few comments and then talk about the next bit. We’ve now just set up the infrastructure to be able to be the scrollView’s delegate so we can do some further required work of our own, but then can also pass on the delegate’s messages to any external class that needs this scrollView’s delegate functionality. Now that we have this, we can actually proceed to adding the task of knowing when the scroll view is scrolling.

Step 2 : Dealing with contentOffset

A UIScrollView has a contentOffset property that can be set directly, or can be animated. The issue is however, if you set the contentOffset and don’t animate it, there are still messages sent to the delegate’s scrollViewDidScroll: callback. This is semantics. It basically means “scroll view did change its contentOffset”. However, for the purposes of knowing when a scrollView is visually scrolling (i.e. you see content moving across the screen), this method does not provide us with the info we need. We need to distinguish whether the contentOffset has changed because it animated or because it was ‘hard set’.

Now you will see above in the class’ private interface why there is a BOOL _isSettingContentOffset; You need to do this any time your custom code calls:

_isSettingContentOffset = YES;
self.contentOffset = somePoint;

(No, you can’t override setContentOffset and add this YES clause, because the UIScrollView itself calls this method internally as well when it actually is scrolling due to motion.)

Now we have to start implementing our UIScrollViewDelegate methods:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
    if (_isSettingContentOffset) 
    {    
        self.scrolling = NO;
    }
    else
    {
        self.scrolling = YES;
    }
    
    if (_isSettingContentOffset) {
        _isSettingContentOffset = NO;
    }
    
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self && [_myDelegate respondsToSelector:@selector(scrollViewDidScroll:)])
    {
        [_myDelegate performSelector:@selector(scrollViewDidScroll:) withObject:self];
    }
}

We’ve now dealt with the unpleasantness associated with contentOffset. Remember that if in your implementation of this subclass you have to programmatically change the contentOffset to change the _isSettingContentOffset = YES;

Step 3 : Complete the Hijacking process and add the scrolling KVO notifications

Now we simply look at the UIScrollViewDelegate methods and add the rest of the scrolling KVO notifications AS WELL AS ensuring all the delegate methods will be re-routed to any ‘real’ delegate.

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
    if (!decelerate) {
        self.scrolling = NO;
    }
    
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self &&
        [_myDelegate respondsToSelector:@selector(scrollViewDidEndDragging:willDecelerate:)])
    {
        [_myDelegate scrollViewDidEndDragging:self willDecelerate:decelerate];
    }
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
    self.scrolling = NO;
    
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self &&
        [_myDelegate respondsToSelector:@selector(scrollViewDidEndDecelerating:)])
    {
        [_myDelegate performSelector:@selector(scrollViewDidEndDecelerating:) withObject:self];
    }
}

- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
{
    self.scrolling = NO;
 
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self &&
        [_myDelegate respondsToSelector:@selector(scrollViewDidEndScrollingAnimation:)])
    {
        [_myDelegate performSelector:@selector(scrollViewDidEndScrollingAnimation:) withObject:self];
    }
}

// and now for completeness...

- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self &&
        [_myDelegate respondsToSelector:@selector(scrollViewWillBeginDragging:)])
    {
        [_myDelegate performSelector:@selector(scrollViewWillBeginDragging:) withObject:self];
    }
}

- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView   // called on finger up as we are moving
{
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self &&
        [_myDelegate respondsToSelector:@selector(scrollViewWillBeginDecelerating:)])
    {
        [_myDelegate performSelector:@selector(scrollViewWillBeginDecelerating:) withObject:self];
    }
}

- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView
{
    // check to see if I am my own delegate and then prevent infinite loop.
    if (_myDelegate != (id<UIScrollViewDelegate>)self &&
        [_myDelegate respondsToSelector:@selector(scrollViewDidScrollToTop:)])
    {
        [_myDelegate performSelector:@selector(scrollViewDidScrollToTop:) withObject:self];
    }
}

Long blog post, but hopefully this will be helpful to you. Create a subclass and add this stuff to it. Try it out by putting one of these in a view controller and have the view controller observe the scrolling property. And have the observe callback set a label’s text to scrolling or not scrolling. Then you’ll see.