SwiftUI: iPad-style Popovers on iPhone

One aspect of iOS development that always struck me as funny is how Apple’s default of Popover is different on iPad and iPhone. Basically on iPhone the standard popover is shown like an Action Sheet.

But there are certainly times where you want a popover on iPhone, like a simple delete confirmation, for example. You tap on a trash can icon, and you’d like a quick and tiny confirmation button to appear in context without the jarring experience of the entire screen being taken over by an action sheet. Or even the bottom 1/4 of the screen, especially when that button was on the navigation bar at the top right.

There are plenty of solutions to this in UIKit, but sadly none (yet) with SwiftUI. The .popover view modifier decides for you whether it will be a sheet or a popover, so basically here’s another example of SwiftUI being powerful but not yet fully filled out in terms of components available to us.

Full disclosure: I feel inclined to write a bit of a blog post about this, even though all I or anyone cares about is a solution to the problem. So here we go. I admit I didn’t write this, and so want to give a huge shout out and thank you to Chase Wasden, located here. I’ve taken his gist located here, then modified it to support being able to specify where you’d like your arrows to be, and here it is below:

//
//  WithPopover.swift
//  PopoverSwiftUI
//

// THANK YOU to this wonderful human:  https://gist.github.com/ccwasden/02cbe25b94eb6e844b43442427127e09

import SwiftUI

// -- Usage
/*
struct Content: View {
    @State var open = false
    @State var popoverSize = CGSize(width: 300, height: 300)
    
    var body: some View {
        WithPopover(
            showPopover: $open,
            arrowDirections: [.down],
            popoverSize: popoverSize,
            content: {
                // The view you want to anchor your popover to.
                Button(action: { self.open.toggle() }) {
                    Text("Tap me")
                }
            },
            popoverContent: {
                VStack {
                    Button(action: { self.popoverSize = CGSize(width: 300, height: 600)}) {
                        Text("Increase size")
                    }
                    Button(action: { self.open = false}) {
                        Text("Close")
                    }
                }
            })
    }
}
 */


// -- Source

struct WithPopover<Content: View, PopoverContent: View>: View {
    
    @Binding var showPopover: Bool
    var popoverSize: CGSize? = nil
    var arrowDirections: UIPopoverArrowDirection = [.down]
    let content: () -> Content
    let popoverContent: () -> PopoverContent
    
    var body: some View {
        content()
            .background(
                Wrapper(showPopover: $showPopover, arrowDirections: arrowDirections, popoverSize: popoverSize, popoverContent: popoverContent)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            )
    }
    
    struct Wrapper<PopoverContent: View> : UIViewControllerRepresentable {
        
        @Binding var showPopover: Bool
        var arrowDirections: UIPopoverArrowDirection
        let popoverSize: CGSize?
        let popoverContent: () -> PopoverContent
        
        func makeUIViewController(context: UIViewControllerRepresentableContext<Wrapper<PopoverContent>>) -> WrapperViewController<PopoverContent> {
            return WrapperViewController(
                popoverSize: popoverSize,
                permittedArrowDirections: arrowDirections,
                popoverContent: popoverContent) {
                self.showPopover = false
            }
        }
        
        func updateUIViewController(_ uiViewController: WrapperViewController<PopoverContent>,
                                    context: UIViewControllerRepresentableContext<Wrapper<PopoverContent>>) {
            uiViewController.updateSize(popoverSize)
            
            if showPopover {
                uiViewController.showPopover()
            }
            else {
                uiViewController.hidePopover()
            }
        }
    }
    
    class WrapperViewController<PopoverContent: View>: UIViewController, UIPopoverPresentationControllerDelegate {
        
        var popoverSize: CGSize?
        let permittedArrowDirections: UIPopoverArrowDirection
        let popoverContent: () -> PopoverContent
        let onDismiss: () -> Void
        
        var popoverVC: UIViewController?
        
        required init?(coder: NSCoder) { fatalError("") }
        init(popoverSize: CGSize?,
             permittedArrowDirections: UIPopoverArrowDirection,
             popoverContent: @escaping () -> PopoverContent,
             onDismiss: @escaping() -> Void) {
            self.popoverSize = popoverSize
            self.permittedArrowDirections = permittedArrowDirections
            self.popoverContent = popoverContent
            self.onDismiss = onDismiss
            super.init(nibName: nil, bundle: nil)
        }
        
        override func viewDidLoad() {
            super.viewDidLoad()
        }
        
        func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
            return .none // this is what forces popovers on iPhone
        }
        
        func showPopover() {
            guard popoverVC == nil else { return }
            let vc = UIHostingController(rootView: popoverContent())
            if let size = popoverSize { vc.preferredContentSize = size }
            vc.modalPresentationStyle = UIModalPresentationStyle.popover
            if let popover = vc.popoverPresentationController {
                popover.sourceView = view
                popover.permittedArrowDirections = self.permittedArrowDirections
                popover.delegate = self
            }
            popoverVC = vc
            self.present(vc, animated: true, completion: nil)
        }
        
        func hidePopover() {
            guard let vc = popoverVC, !vc.isBeingDismissed else { return }
            vc.dismiss(animated: true, completion: nil)
            popoverVC = nil
        }
        
        func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
            popoverVC = nil
            self.onDismiss()
        }
        
        func updateSize(_ size: CGSize?) {
            self.popoverSize = size
            if let vc = popoverVC, let size = size {
                vc.preferredContentSize = size
            }
        }
    }
}

This is also an excellent example for learning how to make UIKit and SwiftUI play with each other.

UILabel when inactive, UISlider when active

I’m working on a new app and I’m trying to keep the interface minimal in terms of screen elements.  Also, design-wise I’m a huge fan of finding ways to only use space *when* you need it, and finding novel ways to conceal elements that you don’t need (when you don’t need them).  On the flipside, one should also be conscious of hiding too much, thus alienating some users who don’t immediately have a good sense for such things.  That addresses the need for good onboarding but that’s a longer discussion.

So I thought, (the new app is a music player), “it’s not often you need to seek to a certain time in the song, so why does this element get to take up so much space?”

Since my app’s UX generally involves interacting with labels, I thought it would be interesting to hide a slider behind a label, but when you interact with it, it’s the underlying control that becomes active.

I think I should just show the code.  The key is in how one overrides the method -hitTest:withEvent:  Because of that, this solution is very flexible in terms of what kind of UISlider you use, and what kind of UILabel you use.  It’s the technique worth noting because it could have a few applications.

Shoot me a message if you don’t quite understand why this does what it does, or why a different approach might / might not work for you.

(Oh yeah, the assumption is that the subviews are pinned to their parent’s edges… i.e. same frame.size)

/  LabelScrubber.swift
//  LabelScrubber
//
//  Created by Stephen O'Connor on 03.11.20.
//  Copyright © 2020 HomeTeam Software. All rights reserved.
//

import UIKit

class LabelScrubber: UIView {

    let showDelayShort = 0.7
    let showDelayLong = 1.2
    
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var slider: UISlider!
    
    var labelTimer: Timer?
    
    /// this gets called on a touch down, so in other words, at the beginning of an interaction
    /// but you tell it which view you want to be 'the view' for the interaction.
    /// super cool; so you can override and say it's the slider that matters
    /// and thus you'll interact with that.
    /// nice though that we can make preparations before that interaction,
    /// like snapping the slider to where your touch is!
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if self.bounds.contains(point) {
            self.slider.isHidden = false
            self.label.isHidden = true
            
            let percentX = point.x / self.bounds.size.width
        
            let targetValue = self.slider.minimumValue + Float(percentX) * (self.slider.maximumValue - self.slider.minimumValue)
            
            self.slider.setValue(targetValue, animated: true)
            
            showLabel(after: showDelayLong)
            return self.slider
        }
        return super.hitTest(point, with: event)
    }
    
    override var intrinsicContentSize: CGSize {
        return label.intrinsicContentSize
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override func awakeFromNib() {
        super.awakeFromNib()
        commonInit()
    }
    
    private func commonInit() {
        self.label.isUserInteractionEnabled = false
        self.label.isHidden = false
        self.slider.isHidden = true
        self.slider.addTarget(self, action: #selector(tracking(_:)), for: .touchDown)
        self.slider.addTarget(self, action: #selector(tracking(_:)), for: .valueChanged)
        self.slider.addTarget(self, action: #selector(finishedTracking(_:)), for: .touchUpInside)
        self.slider.addTarget(self, action: #selector(finishedTracking(_:)), for: .touchUpOutside)
    }
    
    @objc
    func finishedTracking(_ slider: UISlider) {
        showLabel(after: showDelayShort)
    }
    
    @objc
    func tracking(_ slider: UISlider) {
        invalidateShowTimer()
    }
    
    private func showLabel(after delay: TimeInterval) {
        print("willShowLabel")
        invalidateShowTimer()
        labelTimer = Timer.scheduledTimer(withTimeInterval: delay,
                                          repeats: false,
                                          block:
            { [weak self] (_) in
                print("showLabel\n\n")
                self?.returnToDefaultState()
                
        })
    }
    
    func invalidateShowTimer() {
        print("invalidate")
        labelTimer?.invalidate()
    }
    
    func returnToDefaultState(duration: TimeInterval = 0.3) {

        self.slider?.alpha = 1.0
        UIView.animate(withDuration: duration,
                       animations: {
                        //self.label?.alpha = 1.0
                        self.slider?.alpha = 0.0
        }) { [weak self] (_) in
            self?.label?.isHidden = false
            self?.slider?.isHidden = true
            self?.slider?.alpha = 1.0
        }
    }
}

Hacking MPMediaQuery for better album shuffling

So I’m working on a Music app.  I’m a little frustrated with Apple’s assumption that we all have massive iCloud limits and/or live in a country with great mobile reception.  I live in Germany and as soon as you leave the cities, your mobile reception gets really sketchy unless you are a subscriber of the state “monopoly” Deutsche Telekom.  German bureaucracy dictates that anything with “Deutsche” in the business name will be grossly overpriced and half as effective, while customer service being usually horrible.

This is a post about programming!  😀  So yeah, I’m working on an app that’s to be a lot like the original Music app on the iPod touch: Just show me the songs I have on this device, and make it easy for me to navigate around.

One thing that became apparent was MPMusicPlayerController‘s inability to pick random albums.  It can pick random songs, but not random albums and then play the album.  It can first shuffle through the songs in an album before moving on to another album, but really?  Why would I insult an artist by listening to their album in the wrong order?  Can you imagine listening to Pink Floyd’s Dark Side of the Moon with the tracks shuffled?  Makes no sense, except perhaps a Greatest Hits album…

So I thought “why don’t I just hack with the MPMediaQuery class?  And I found a solution that works.  At least if you aren’t using this query for UITableView and populating all that, as you’d require itemSections, and collectionSections, etc.

So, without going into too much more detail, if you have already got a bit of experience with using the MediaPlayer framework on iOS, you’ll know that it’s a little quirky, as most of the audio folks tend to be over at Apple / everywhere (former Audio Designer here)…

import UIKit
import MediaPlayer

class MPHackMediaQuery: MPMediaQuery {

    private var modifiedItems: [MPMediaItem]?
    private var modifiedCollections: [MPMediaItemCollection]?
    
    init(with query: MPMediaQuery) {
        super.init(filterPredicates: query.filterPredicates)
        self.groupingType = query.groupingType
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override var items: [MPMediaItem]? {
        if modifiedCollections == nil {
            if let collections = super.collections {
                modifiedCollections = self.randomize(collections)
            }
        }
        if modifiedItems == nil {
            if let collections = self.modifiedCollections {
                var items = [MPMediaItem]()
                for collection in collections {
                    items.append(contentsOf: collection.items)
                }
                modifiedItems = items
                
            } else {
                return nil
            }
        }
        
        return modifiedItems
    }
    
    override var collections: [MPMediaItemCollection]? {
        
        if modifiedCollections == nil {
            if let collections = super.collections {
                modifiedCollections = self.randomize(collections)
            }
        }
        
        return modifiedCollections
    }
    
    private func randomize(_ collections: [MPMediaItemCollection]) -> [MPMediaItemCollection] {
        
        var indices: [Int] = []
        for i in 0.. 0
        
        return shuffledCollections
    }
}

So as you can see, you can set a queue on your MPMusicPlayerController using a MPMediaQuery and this will work… IF you set your controller’s shuffleMode to .off, which makes sense because we aren’t shuffling the songs of the album, we just “pre-shuffled” the order in which the albums are chosen.

[Swift] Detect Touches on Attributed Text in UILabel

It’s always funny when you google “how do I…” and Google shows you a result that you actually wrote.

I wanted to solve this problem, and it looks like I did it years ago in Objective-C.

So, I re-wrote it in Swift that you can basically just copy-paste-use.  You’re welcome.

//
//  TappableLabel.swift
//
//  Created by Stephen O'Connor on 07.10.20.
//  MIT License.  You will send no lawyers here.  Have fun.
//  taken from here:  https://horseshoe7.wordpress.com/2015/12/10/detect-touches-on-attributed-text-in-uilabel/

import UIKit

typealias LabelLink = (text: String, link: Any?)

protocol TappableLabelDelegate: class {
    func didTapOnLink(_ link: LabelLink, in tappableLabel: TappableLabel)
}

extension NSAttributedString.Key {
    static let custom = NSAttributedString.Key("CustomAttribute")
}

class TappableLabel: UILabel {
    
    weak var delegate: TappableLabelDelegate?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override func awakeFromNib() {
        super.awakeFromNib()
        commonInit()
    }
    
    private func commonInit() {
        self.textAlignment = .left // has to be left for this to work!
        addGestureRecognizers()
    }
    
    private func addGestureRecognizers() {
        let tap = UITapGestureRecognizer(target: self, action: #selector(tappedLabel(_:)))
        self.isUserInteractionEnabled = true
        self.addGestureRecognizer(tap)
        
    }
    
    @objc
    private func tappedLabel(_ tap: UITapGestureRecognizer) {
        guard let label = tap.view as? TappableLabel, label == self, tap.state == .ended else {
            return
        }
        let location = tap.location(in: label)
        processInteraction(at: location, wasTap: true)
    }
    private func processInteraction(at location: CGPoint, wasTap: Bool) {
        
        let label = self
        
        guard let attributedText = label.attributedText else {
            return // nothing to do
        }
        
        let textStorage = NSTextStorage(attributedString: attributedText)
        let textContainer = NSTextContainer(size: label.bounds.size)
        let layoutManager = NSLayoutManager()
        layoutManager.addTextContainer(textContainer)
        textStorage.addLayoutManager(layoutManager)
        
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = label.lineBreakMode
        textContainer.maximumNumberOfLines = label.numberOfLines
        
        
        let characterIndex = layoutManager.characterIndex(for: location,
                                                          in: textContainer,
                                                          fractionOfDistanceBetweenInsertionPoints: nil)
        if characterIndex < textStorage.length {
            log.info("Character Index: \(characterIndex)")
            let range = NSRange(location: characterIndex, length: 1)
            let substring = (attributedText.string as NSString).substring(with: range)
            
            log.info("Character at Index: \(substring)")
            if let labelLink = attributedText.attribute(.custom,
                                                        at: characterIndex,
                                                        effectiveRange: nil) as? LabelLink {
                
                log.debug("You \(wasTap ? "tapped" : "pressed") on \(labelLink.text) and the value is: \(String(describing: labelLink.link))")
                self.delegate?.didTapOnLink(labelLink, in: self)
            }
        }
    }
    
    // will set the label's text to the given text argument, but for any callbackString it will search the text for that and embed it.
    func setText(_ text: String, withCallbacksOn callbackStrings: [LabelLink] = []) {
        
        self.text = text
        
        let attributedString = NSMutableAttributedString(string: text)
        let coreAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor : self.textColor!,
            .font: self.font!
        ]
        attributedString.setAttributes(coreAttributes,
                                       range: NSRange(location: 0, length: text.count))
        
        for labelLink in callbackStrings {
            
            let range = (text as NSString).range(of: labelLink.text)
            if range.location != NSNotFound {
                var additionalAttributes = coreAttributes
                additionalAttributes[.custom] = labelLink
                attributedString.setAttributes(additionalAttributes, range: range)
            }
        }
        self.attributedText = attributedString
    }
}

UIScrollView that adapts to fit content

So I have to say, I have had some difficulty getting UIScrollView to play nicely with Autolayout. There are posts such as here that look to be the solution to many people’s problems. I didn’t really get it working for me.

So I thought I’d write what did. To outline what I’d like to do, I’d like to avoid re-purposing UITableView and cells to display content. It is a lot more boilerplate and it adds complexity. That said, you can have content that is infinitely long and it will be displayed efficiently.

Basically I’d like to create a content view that is potentially larger or shorter than the iPhone screen. Using a UIScrollView is a good candidate for this.

I’ll just get to the summary of the approach. I’ll create a contentView that has its width pinned to the scroll view’s (ultimately the screen’s) width, pinned at the top, but be flexible to allow this contentView to be as large or as small as it needs to be (for example because you have a text view).

I did this by making sure that I pin the scrollView to the superview’s edges, then I add a contentView to the scrollView and pin its top, left, right edges, and pin its width to be the scrollView’s width.

Assuming the component in my contentView is a UITextView, and thus has content that is dynamic, whenever I add this to my storyboard, I make sure that scrolling is disabled on the text view. Then I add an outlet for a NSLayoutConstraint for the text view’s height. I use the delegate callback of UITextView (textViewDidChange) to calculate the ideal height of the text view, then use that height to set the constant of the layout constraint I just mentioned. This resizes the text view to its dynamic content, and thus the size of the contentView.

    func textViewDidChange(_ textView: UITextView) {
        resizeTextViewToFitContent()
    }
    
    func resizeTextViewToFitContent() {
        self.textViewHeightConstraint.constant = self.textView.sizeThatFits(CGSize(width: self.textView.frame.size.width, height: CGFloat.greatestFiniteMagnitude)).height
    }



If I set this up in interface builder and don’t pin the bottom of the contentView, I will get an error about the scrollView having ambiguous content height. This can be suppressed by setting the Ambiguity to “never verify”.

Now all I need is a custom scroll view that will update its content size whenever the contentView changes size:

class ContentScrollView: UIScrollView {
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // alternatively, you can set an IBOutlet weak var contentView: UIView! instead of assuming one subview.
        if let contentView = self.subviews.first {
            self.contentSize = contentView.bounds.size
        }
    }
}

And that’s it! How to make dynamic content be scrollable where required. You’ll notice that if your contentSize is less than the screen height, no scrolling, no scroll indicators; you’ll never even know it’s embedded in a scrollView.

To summarize:

  1. Add UIScrollView inside the main view in Storyboard
  2. Add UIView inside the UIScrollView
  3. Add UITextView inside the UIView (the view added in step 2)
  4. Make sure “Scrolling Enabled” of UITextView is unchecked
  5. Add 4 constraints (leading, trailing, top, bottom) on UIScrollView
  6. Add 3 constraints (leading, trailing, top) on UIView (the view added in step 2)
  7. Add “Width Equally” constraint on UIView (the view added in step 2) and the main view
  8. Add 5 constraints (leading, trailing, top, bottom, height) on UITextView. After this step you shouldn’t get any errors and warnings on constraints.
  9. Add UITextView height constraint IBOutlet on the ViewController. @IBOutlet weak var textViewHeightConstraint: NSLayoutConstraint! and connect it in Storyboard
  10. Make your view controller conform to UITextViewDelegate and implement methods listed above, and make sure the text view’s delegate is this view controller.
  11. If you set the text programmatically, call resizeTextViewToFitContent
  12. In your storyboard, for the scrollView, set Ambiguity to “never verify”
  13. Make sure the UIScrollView is the ContentScrollView given above.

Nostalgia for old iOS

This sounds silly, but I kind of miss iOS development in the days before iOS 7. I kind of have this feeling the entire ecosystem has become less stable, less reliable, not as clear in its vision, and basically less easy than it used to be.

Don’t believe me?  Just look at all the different ways Apple has tried to solve Autorotation.  That API has just changed so many times.

The thing that actually inspired this post is actually not related to nostalgia because it’s an issue that has always been less than straightforward.  All I want to do is tell my navigation bar that the back button should have no text (just the arrow).

There are all sorts of posts on this, but none of them actually work.  Change the title of the back button in viewWillAppear: ??  Nope.  Override the navigationItem variable and set its back button to an item with no title?  Nope….

Just sometimes simple things in iOS are annoyingly not that way.

HSHTMLImageRenderer !

So with permission of my current client (for whom I wrote the original code), I’m able to open source this component for all to use. I’m delighted I can do that because I think this component can be of good use to people.

HSHTMLImageRenderer is a way to be able to render HTML offscreen to images that can be stored in a cache.  It’s useful when you have many layout elements whose content is a small bit HTML that could be complicated and unsuitable for a UILabel, and it’s not feasible to have numerous instances of UIWebView everywhere.

Have a look. It’s over on my github page.

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.

UITableView with Animating Section Header

So, I’ve been scratching my head on an issue I’ve been having:

I want to have a UITableView with a section header, because I want it to stick to the top of the screen while scrolling.  The thing is, it’s supposed to have a search bar that expands when you enter “search mode”.

What you see in the video is my prototype.  Tapping a table cell is supposed to toggle this search mode on and off.  In the video above you see that that table cell layout updates and expands, but the section header seems to be “one click behind” and expands when the cell layouts push up because they think the header is shorter.

This behaviour happened via the standard posts on stack overflow.  Here, for example.

Now, perhaps my issue is because I also have a stretchy section header, which I based off of this post:

Anyway, I found the solution involved a slightly complicated solution, but once it’s in place, there’s little to know (i, ii, iii, …) and do (A, B, C, D, …) :

i)  I assume you will have one section on your table view
A) Define an associated view on your view controller, which will be your “expandableHeaderView” (more on the specifics later)
Screenshot 2016-03-17 14.03.04

B) Define your class to have such properties and methods:

IB_DESIGNABLE
@interface QLExpandableSectionHeaderTableViewController : UITableViewController

@property (nonatomic, assign) IBInspectable NSInteger expandableSectionHeaderInactiveHeight;  // defaults to 44
@property (nonatomic, assign) IBInspectable NSInteger expandableSectionHeaderActiveHeight; // defaults to 88

@property (nonatomic, strong) IBOutlet UIView *expandableHeaderView;

@property (nonatomic, readwrite, getter=isHeaderExpanded) BOOL headerExpanded;

- (void)setSectionHeaderHeightExpanded:(BOOL)expanded animated:(BOOL)animated;

@end


@implementation QLExpandableSectionHeaderTableViewController

- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
    self = [super initWithCoder:aDecoder];
    if (self) {
        _expandableSectionHeaderInactiveHeight = 44.f;
        _expandableSectionHeaderActiveHeight = 88.0f;
    }
    return self;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    if (!self.expandableHeaderView) {
        NSLog(@"WARNING: You haven't provided an associated header view you want to use when toggling section header height!");
    }
    else
    {
        // this has to be clear, since animation doesn't quite work as desired.
        self.expandableHeaderView.backgroundColor = [UIColor clearColor];
    }
}


- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section
{
    if (section == 0) {
        if (self.isHeaderExpanded) {
            return self.expandableSectionHeaderActiveHeight;
        }
        return self.expandableSectionHeaderInactiveHeight;
    }
    
    return 0;
}

- (UIView*)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section
{
    if (section == 0) {
        
        if (self.expandableHeaderView) {
            return self.expandableHeaderView;
        }
        else
        {
            // will probably never get used
            UIView *header = [UIView new];
            header.backgroundColor = [UIColor redColor];
            header.frame = CGRectMake(0, 0, self.tableView.bounds.size.width, self.expandableSectionHeaderInactiveHeight);
            return header;
        }
    }
    

    return nil;
}

- (void)toggleSectionHeader:(UIButton*)button
{
    if (self.isHeaderExpanded) {
        [self setSectionHeaderHeightExpanded:NO animated:YES];
    }
    else {
        [self setSectionHeaderHeightExpanded:YES animated:YES];
    }
}

- (void)setSectionHeaderHeightExpanded:(BOOL)expanded animated:(BOOL)animated
{
    if (self.isHeaderExpanded && expanded) {
        return;  // nothing to do!
    }
    
    self.headerExpanded = expanded;
    [self.tableView beginUpdates];
    [self.tableView endUpdates];
    
    //NSLog(@"%@", self.searchHeaderView.constraints);
    __block NSLayoutConstraint *headerHeightConstraint = nil;
    [self.expandableHeaderView.constraints enumerateObjectsUsingBlock:^(__kindof NSLayoutConstraint * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        
        // I determined this name by logging the self.searchHeaderView.constraints and noticed
        // it had a constant value that was one of my searchBar(In)activeHeight values.
        if ([obj.identifier isEqualToString:@"UIView-Encapsulated-Layout-Height"]) {
            headerHeightConstraint = obj;
            *stop = YES;
        }
    }];
    
    CGFloat newHeight = expanded ? self.expandableSectionHeaderActiveHeight : self.expandableSectionHeaderInactiveHeight;
    headerHeightConstraint.constant = newHeight;
    
    // after the animation completes, the expandableHeaderView's frame doesn't change until a layout update
    // , which only happens after you start scrolling.  This will ensure it has the right size as well.
    CGRect frame = self.expandableHeaderView.frame;
    frame.size.height = newHeight;
    
    [UIView animateWithDuration:animated ? 0.3f : 0.0f
                          delay:0.0f
                        options:UIViewAnimationOptionCurveEaseInOut
                     animations:^{
                         [self.tableView layoutIfNeeded];
                     } completion:^(BOOL finished) {
                         self.expandableHeaderView.frame = frame;
                     }];
    
}

The code above will handle animation.  There is still some weirdness happening on the UIKit side of things, because the the header view’s subviews animate according to auto-layout, but the self.expandableHeaderView doesn’t visually change size until a layout update occurs.  And this only occurs once you start scrolling.  So we set that frame to the size it’s supposed to have, and that sorts it all out. As a result, we have to set the self.expandableHeaderView.backgroundColor = [UIColor clearColor];

And that solved the problem! It’s a bit hacky, but I found no other way to accomplish this that isn’t THAT ugly.

And here’s the result:

Storyboard Segues that don’t animate

It’s a bit of a hack as one can’t seem to set this easily in code, but perhaps you have a view controller hierarchy in your Storyboard where you want to guarantee a specific state on app launch, but your entry point is in some base-level container view controller (think HSApplicationViewController).

It would be silly on viewDidLoad to then trigger a bunch of segues, especially if these animate.  The App would start and the first thing you’d see would be transitions through a view controller hierarchy.

My solution for this started via this article on stackoverflow, and is basically just repackaged here.

I tend to keep a naming convention for segue identifiers.  They usually begin with “segue…” for forward segues, and “unwind….” for unwind segues.

Have a look at this: non-animated_segue

The idea is that you duplicate any segue, uncheck the animates box in the inspector pane and add a suffix to it, such as “…NoAnimation”.  So now you would have “segueSomeThing” and “segueSomeThingNoAnimation”, then when you implement your “prepareForSegue:” method, you have the same handler code for both:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
  if ([segue.identifier hasPrefix:@"segueSomeThing"])
  {
    // the key is "hasPrefix". You can even use a string constant for your animated variant
    // same destinationViewController, same setup. Just not animated.
   }
 }

And that’s it.  It does have a bit more management overhead as you have to keep track of hard-coded strings in your Storyboard in 2 places, but perhaps it’s worth it.