acb's technical journal

Posts matching tags 'uitableview'

2015/5/29

A popover menu control for iOS 8

In iOS 8, it is possible to present any view controller in a popover. This blog post describes the construction of a UIControl which presents a popover menu of text options. The full code of the control, and an example program, may be found here.

Sometimes, it is useful to have a popup menu in an iOS app; for the user to be able to change a value by tapping or dragging on the value and selecting a new one from a menu. Traditionally, this has been done on the iPhone with UIActionSheet; the idiom was that the user tapping on the button/table cell would trigger a pane of options, which would display from the bottom of the screen. This made sense for small phone screens, as it allowed one-handed operation, but in the age of the phablet, there is no need to stick to it. Hence, as of iOS 8, it is possible to present arbitrary view controllers in a popover on any iOS device, which is quite powerful.

On the iPad, one could present arbitrary view controllers in a popover view for a while. This deliberately did not support the iPhone, and would cause the app to die with a runtime error if one tried using it. In iOS 8, Apple have changed the interface somewhat, and extended it to the iPhone.

Popover view controllers in iOS 8

In iOS, one presents a new view controller by instantiating it, setting it up and calling the current view controller's presentViewController:animated: method. Traditionally, this has been used to push view controllers onto a UINavigationController stack, or to present modal full-screen view controllers. In iOS 8, the same method can be used to present view controllers in a popover container, which appears on the screen with an arrow pointing to the control it launched from.

To do this, one first sets the view controller's modalPresentationStyle to UIModalPresentationStyle.Popover. One then obtains a UIPopoverPresentationController from the view controller's popoverPresentationController property, and uses this to configure the popover's appearance and behaviour; these include the view on which the popover is being displayed, the frame rectangle of the control it is being launched from (to which the arrow will point), and which arrow directions one wishes to use. One also needs to give it a delegate, and to make it work correctly, define the delegate's adaptivePresentationStyleForPresentationController to return .None. Then, once this is done, one presents the view controller using presentViewController:animated:. The controller may be dismissed in the same way modal view controllers are, by calling its dismissViewControllerAnimated: method. The controller will also be dismissed if the user taps outside of the popover; if this happens, the delegate's popoverPresentationControllerDidDismissPopover: method will be called.

Building a pop-up menu/selector control

This is all very well, but we want something more convenient than setting up a view controller every time we want a popover menu. The goal is to have a self-contained control that handles a few common cases of popover menus. We make the following assumptions:

  1. The popover menu will have one or more items, or options, each of which is a string.
  2. The popover menu will be launched when the user taps on a button.
    1. In some cases, the button will display the currently selected option, and the menu will allow the user to change this.
    2. In other cases, the menu will display a fixed prompt, and the menu will be used to trigger one of several actions.

From this, it's obvious that we want something that will be configured with a list of options and construct the popover and its embedded view controller; also, as the set of options is a list of strings which are tappable items, the view controller looks very much like it would be a UITableViewController. Given that we want the mechanism to be triggered by the button, and (in some cases) to change the button's text, why not make the button part of it, with the popover handling wired into it? So, we'd want:

  1. The entire control will be a UIView subclass, which contains a UIButton, and handles displaying and responding to the popover control when the button is tapped.
  2. The control keeps a list of options, and the index of the currently selected option (if present). It also has various configuration options, such as whether the button is to display the selected item title, and text to display if no item is selected, or if the selected item title is not to be shown.
  3. We want the control to send a notification when its value changes; to do this, we make it a subclass of UIControl, and allow the application to set targets to be notified on the ValueChanged control event.

Going from there, a skeleton of the control starts to look something like this:

class KFPopupSelector: UIControl {
    enum Option {
        case Text(text:String)
    }

    /** The options the user has to choose from */
    var options: [Option] = [] 
    
    /** The currently selected value */
    var selectedIndex: Int? = nil

    /** The text to display on the button if no option is selected */
    var unselectedLabelText:String = "--"

    /** if true, replace the button's text with the currently selected item */
    var displaySelectedValueInLabel: Bool = true

    // -------- The TableViewController used internally in the popover
    
    class PopupViewController: UITableViewController {
       //  ...
    }
    
    var currentlyPresentedPopup: PopupViewController? = nil

    // User interface components
    private let button = UIButton.buttonWithType(.System) as! UIButton

    override func intrinsicContentSize() -> CGSize {
        return button.intrinsicContentSize()
    }

    // Handle a button tap
    func buttonPressed(sender: AnyObject?) { ... }
}

There are a few things to note there; rather than storing options as Strings, we have defined an enum named Option (within KFPopupSelector's namespace). Currently it has only one option, which wraps a String, but it will, in future, allow for the addition of options which consist of something other than a string. I have also chosen to define the view controller class, PopupViewController, in the control's internal namespace, rather than in a separate file; this is something possible in Swift, and as the view controller is both intimately tied to the control which launches it and fairly uncomplicated in its functionality, separating it out made little sense. Finally, we define the intrinsicContentSize method, making it return the button's content size; we want the control to hug the button, and if the button dimensions change (due to changes in its label), we want to push that out to any autolayout constraints the button is connected to.

As this control is fairly compact, all of its code is inside the KFPopupSelector class; as such, all other code fragments in this article will be implicitly inside this class.

Setting up the control

When the control is loaded from a storyboard, we need to set up its components and wire up event handlers; the code which does this is the below:

    override func awakeFromNib() {
        button.setTitle(labelDecoration.apply(unselectedLabelText), forState: .Normal)
        self.addSubview(button)
        button.frame = self.bounds
        button.autoresizingMask = .FlexibleHeight | .FlexibleWidth
        
        button.addTarget(self, action: Selector("buttonPressed:"), forControlEvents:.TouchDown)
    }

Note that we call the buttonPressed: method on TouchDown, not TouchUpInside; we want to show the popover when the user touches the button, rather than when they lift their finger from it (as the normal button behaviour is).

(Also, for the purposes of this example, we will assume that the control will always be constructed from a NIB or storyboard; to manually construct one, we would call the same functionality from init(frame:).)

Handling button taps

When the user taps the button, we need to create and display our popover; which happens like so:

    func buttonPressed(sender: AnyObject?) {
        if options.count > 0 {
            let pvc = PopupViewController(style: UITableViewStyle.Plain)
            pvc.options = options
            pvc.itemSelected = { (index:Int) -> () in 
                pvc.dismissViewControllerAnimated(true) { 
                    self.currentlyPresentedPopup = nil 
                    self.selectedIndex = index
                }
            }
            pvc.modalPresentationStyle = .Popover
            currentlyPresentedPopup = pvc
            
            if let pc = pvc.popoverPresentationController {
                pc.sourceView = self
                pc.sourceRect = button.frame
                pc.permittedArrowDirections = .Any
                pc.delegate = self
            
                viewController!.presentViewController(pvc, animated: true) {}
            }
        }
    }

PopupViewController is the UITableViewController subclass we hinted at earlier; here, we allude to it having two instance variables: a copy of the options, and itemSelected, which is a callback function called with the index of whichever item the user has tapped. We fill these, threading through the control's options, and passing a closure which will handle the user having tapped a menu item (i.e., set the selected index and close the popover). We also add a didSet handler to the control's selectedIndex variable, to send out the appropriate notification, and update the button label if necessary:

    var selectedIndex: Int? = nil {
        didSet {
            updateLabel()
            sendActionsForControlEvents(.ValueChanged)
        }
    }

 . . .

    func updateLabel() {
        if selectedIndex != nil && displaySelectedValueInLabel {
            switch (options[selectedIndex!]) {
            case .Text(let text): buttonText = text
            }
        } else {
            buttonText = unselectedLabelText
        }
    }

(buttonText is another variable getter/setter, which, for convenience, wraps UIButton's setTitle:forState:, and also handles different title decoration options; for more information, see the source code.)

Finding the view controller

One thing you may have noticed in the buttonPressed method is the last line, reading:

    viewController!.presentViewController(pvc, animated: true) {}

We have not defined what viewController is. However, we know that, to present a view controller, we need to do so by calling a method on the current view controller. Which is easy to do if we're doing so from within the view controller, but a tad trickier when the code is encapsulated into a UIView subclass. View controllers are a higher level of abstraction than views; while views deal with concrete interface elements, view controllers deal with the aggregation of those into specific screens or tasks; and normally, there is no reason for views to know about view controllers. However, this is one of those exceptions, in which a view needs to reach up to the higher level to present a temporary view controller. So how do we obtain this?

A naïve solution may be to make it an instance variable, and require the application to fill it in, either by linking it in the storyboard or providing it at view controller setup time. However, this is messy and cumbersome. There is a better way; it is possible to find the current view controller by walking up the responder chain. As such, we can define a magic viewController variable which gives us the view controller, like so:

    private var viewController: UIViewController? {
        for var next:UIView? = self.superview; next != nil; next = next?.superview {
            let responder = next?.nextResponder()
            if let vc = responder as? UIViewController {
                return vc
            }
        }
        return nil
    }

More on PopupViewController

Until now, we have mostly glossed over PopupViewController, the view controller that sits in the popover. And, for the most part, it is fairly straightforward; if you've ever worked with table views in iOS, you'll find it familiar. There are a few details worthy of remark, and below is an abridged version of PopupViewController highlighting them. (I have omitted the usual UITableViewDataSource methods, as those are fairly straightforward.)

    class PopupViewController: UITableViewController {
        let minWidth: CGFloat = 40.0
        var optionsWidth: CGFloat = 40.0
        
        private let tableViewFont = UIFont.systemFontOfSize(15.0)

        var options: [Option] = [] {
            didSet {
                optionsWidth = options.map { 
                    $0.intrinsicWidthWithFont(self.tableViewFont) 
                }.reduce(minWidth) { max($0, $1) }
            }
        }

        var itemSelected: ((Int)->())? = nil
        
        override var preferredContentSize: CGSize { 
            get {
                tableView.layoutIfNeeded()
                return CGSize(width:optionsWidth+32, height:tableView.contentSize.height)
            }
            set {
                println("Cannot set preferredContentSize of this view controller!")
            }
        }
        
        override func viewWillAppear(animated: Bool) {
            super.viewWillAppear(animated)
            tableView.scrollEnabled = (tableView.contentSize.height > tableView.frame.size.height)
        }
        
        override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
            itemSelected?(indexPath.row)
        }
    }

The main things to note have to do with dealing with the view controller's size. As it is presented in a variable-sized popover, iOS queries our view controller's preferredContentSize for an ideal size. We obtain this size by a two-step process: the height is calculated by making the table view lay itself out and getting the height of its internal scrollable area. We calculate the width is calculated when the list of options is set, by finding the widest option (using map and reduce); we then add 32 to this (as there are 16 points on each side of the label in a standard UITableViewCell). To assist in calculating the width, we add an intrinsicWidthWithFont method to the Option enum, like so:

    enum Option {
        case Text(text:String)
        
        func intrinsicWidthWithFont(font: UIFont) -> CGFloat {
            switch(self) {
            case Text(let t): return NSString(string:t).boundingRectWithSize(
                    CGSize(width:1000, height:1000), 
                    options:.allZeros, 
                    attributes:[NSFontAttributeName: font], 
                    context:nil
                ).width
            }
        }
    }

In the viewWillAppear method, we check if the table view's contents fit within its screen space, and if so, disable scrolling (having the menu move as the user touches it would look a tad disconcerting). If the menu is too big for the popover, scrolling is enabled.

Finally, when the user selects a cell, we just call the callback which was passed.

Making it draggable

So now we have the basics of the popup selector control working; when the user taps the button, it shows a menu in the popover. They can then tap an item, which will close it, set the control's selectedItem, and send out a valueChanged control event. Which is almost what we want. One more thing we'd like to have would be to allow the user to select an item with one gesture; rather than tapping the button and then tapping the item, the user should be able to put their finger on the button, drag to the item in the newly opened popover and release their finger, selecting it. As UITableView doesn't handle drags originating from outside its bounds (not to mention before it was created), we will need to do this manually; and the way we will do so is by attaching a UIPanGestureRecognizer to the control.

We add one line to awakeFromNib:

    override func awakeFromNib() {
.....
        self.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: Selector("dragged:")))
    }

And define the drag handler as follows:

    func dragged(sender: UIPanGestureRecognizer!) {
        if let tableView = currentlyPresentedPopup?.tableView {
            switch(sender.state) {
            case .Changed:
                if let popupContainer = tableView.superview {
                    let pos = sender.locationInView(tableView)
                    if let ip = tableView.indexPathForRowAtPoint(pos) {
                        tableView.selectRowAtIndexPath(ip, animated: false, scrollPosition:.None)
                    }
                }
            case .Ended:
                if let ip = tableView.indexPathForSelectedRow() {
                    currentlyPresentedPopup!.dismissViewControllerAnimated(true) { 
                        self.currentlyPresentedPopup = nil
                        selectedIndex = ip.row
                    }
                }
            default: break
            }
        }
    }

When the drag position moves, it checks if it hits a table row, and if so, selects it; when it ends, if a row has been selected, it stores its value in the control, and closes the popover.

Further steps

The KFPopupSelector control, as described here, performs a simple case of wrapping a popover menu into a control; however, there is plenty of room for expansion.

One obvious place to expand would be to allow options for values other than strings; images would be one possibility, or colours from a palette. This would involve adding cases to the Option enum, like so:

    enum Option {
        case Text(text:String)
        case Image(img: UIImage)
    }

...and, of course, making the appropriate changes in table view cell creation, width estimation and such.

Another possibility would be to replace UITableView with UIContainerView, allowing non-vertical layouts. This would be handy if the options are images or colours, which may be laid out on a grid, or for doing horizontal menus of short pieces of text, as seen in the iOS cut/paste popover.

cocoa touch ios swift uipopoverpresentationcontroller uitableview 0

2015/5/25

When self-sizing UITableView cells don't

One of the advances in iOS 8 was self-sizing UITableView cells. In earlier versions of iOS, if one wanted a table view whose cells' height varied, one had to explicitly calculate the height for each cell, providing a method which returned it; a process involving considerable string-measuring. Since iOS 8, this is no longer necessary; it is now possible to set a UITableView's rowHeight to a new magic value, UITableViewAutomaticDimension; this causes the table view to evaluate each cell's auto-layout constraints, determining its size. Of course, this means that one has to design one's custom cells in such a way that the constraints “push outwards” on the top and bottom edges of the content view, expanding it as the intrinsic sizes of the contained subviews increase. Still, that makes auto-sizing table view cells a lot simpler, especially for complex cells; in theory, at least.

This mechanism is not without its problems. There is an intermittent bug which, in some instances, causes cells to not be sized until the second time they are shown. In other words, when the cell first appears, it is in the default size it is on the storyboard, and any text that does not fit on labels is truncated. If the user scrolls the cell off the screen and then scrolls back to it, it reappears properly sized. Or, taking an example (taken from my MPDluxe MPD controller app):

The above view is a UITableView showing the contents of a directory on a music server. The left image is what appears when the user first enters the directory; while the second cell is sized correctly, the third cell is not, and the title is cut off. The right image is what appears if the user scrolls down (moving the top cells off the screen) and then scrolls back up; here, the third cell is sized properly.

Given that a second displaying operation causes the cell's size to be calculated properly, one workaround may be to force the cells to be reloaded when the view appears; i.e., adding to the view controller:

    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        self.tableView.reloadData()
    }
It turns out that this doesn't work; reloadData is called too early, and is coalesced into the initial load, having no effect. So let's add a delay:
    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        dispatch_after(
            dispatch_time(DISPATCH_TIME_NOW, Int64(0.1 * Double(NSEC_PER_SEC))),
            dispatch_get_main_queue()) {
                self.tableView.reloadData()
        }
    }
That's better; the cells that appear at the start are now sized correctly. However, if there are any mis-sized cells below the first screenful, they will still appear incorrectly sized the first time the user scrolls to them. Which means that we need something subtler than reloading the whole table view at start time.

Fortunately for us, the UITableViewDelegate protocol has an optional method titled tableView:willDisplayCell:forRowAtIndexPath:. As the name suggests, if defined, this is called just before a cell is displayed. Which would allow us to force a reload of the cell.

override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
    dispatch_after(
        dispatch_time(DISPATCH_TIME_NOW, Int64(0.1 * Double(NSEC_PER_SEC))),
        dispatch_get_main_queue()) {
             tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .None)
        }
}
Which does the trick; the cells appear with the correct size now. However, scrolling is also noticeably slower. The problem is, each cell is reloaded every time it appears, whether it has been resized or not. So the next step is to add a memory of which cells have been reloaded, and not reload any cell more than once:
var alreadyReloaded: Set = Set()

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    alreadyReloaded = Set()
}

...

override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
    // we assume that there will never be more than 2^16 sections or rows in a section
    let index = indexPath.row | (indexPath.section << 16)
    if(!alreadyReloaded.contains(index)) {
        alreadyReloaded.insert(index)
        dispatch_after(
            dispatch_time(DISPATCH_TIME_NOW, Int64(0.1 * Double(NSEC_PER_SEC))),
            dispatch_get_main_queue()) {
                tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .None)
            }
    }
}
This will cause each cell to be reloaded only once.

Of course, this is just a beginning; if the user scrolls down rapidly, it will still trigger a flurry of reloads. The next step would involve application-specific logic, checking whether a cell can be eliminated as a candidate for reloading based on model information (for example, is it a type of cell which suffers from the problem, or is the information in the model particularly long).

autolayout ios uitableview workaround 0

This will be the comment popup.
Post a reply
Display name:

Your comment:


Please enter the text in the image above here: