acb's technical journal

Posts matching tags 'autolayout'

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

2015/3/15

Swift snippets: flatMap and layout constraints

Since version 6, iOS has had a constraint-based layout engine for laying out views The constraint-based layout engine in iOS is considerably more powerful than the old system of auto-layout hints, allowing reasonably sophisticated flexible layouts to be specified purely as systems of constraints. However, it can be cumbersome to set up if creating all constraints individually; any reasonably complicated layout will soon have dozens of NSLayoutConstraint(item:attribute:relatedBy:toItem:attribute:multiplier:constant:) lines padding out its construction code, making one wonder why not just cut the Gordian knot and write code to manually lay out the view, as in the old days?

Fortunately, Apple provide a shortcut: the NSLayoutConstraint.withVisualFormat method; which takes a string describing the relationships between elements in an ASCII-art-style syntax known as the Visual Format Language, a few options and a dictionary of elements and emits a list of NSLayoutConstraints, as so:

let viewsDict:[NSObject:AnyObject] = [
    "lBtn" : leftButton,
    "rBtn" : rightBtn,
    "title" : titleLabel
]
let constraints = NSLayoutConstraint.constraintsWithVisualFormat("H:|-12-[lBtn(30)]-6-[title]-6-[rBtn(30)]-12-|", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict)
myView.addConstraints(constraints)

As implied, each constraintsWithVisualFormat call takes a line of visual format language and produces an array of zero or more constraints; the line above, for example, would produce six; four distance constraints and two size constraints. However, one such line is rarely enough to unambiguously specify the constraints for a view and its contents; most layouts would require more than one line of visual format language to specify their constraints. The above, for example, covers only horizontal constraints; adding vertical ones to the mix would involve a second line, like so:

let constraints = NSLayoutConstraint.constraintsWithVisualFormat("H:|-12-[lBtn(30)]-6-[title]-6-[rBtn(30)]-|", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict) + 
    NSLayoutConstraint.constraintsWithVisualFormat("V:|-10-[title(20)]", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict)
myView.addConstraints(constraints)
Which gives two more constraints on the title label view: a distance from the top edge of the superview and a height. But what about the buttons?
let constraints = NSLayoutConstraint.constraintsWithVisualFormat("H:|-12-[lBtn(30)]-6-[title]-6-[rBtn(30)]-|", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict) + 
    NSLayoutConstraint.constraintsWithVisualFormat("V:|-10-[title(20)]", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict) +
    NSLayoutConstraint.constraintsWithVisualFormat("V:|-5-[lBtn(30)]", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict) +
    NSLayoutConstraint.constraintsWithVisualFormat("V:|-5-[rBtn(30)]", 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict)
myView.addConstraints(constraints)
All of which soon starts to get somewhat unwieldy; there's a lot of repeated boilerplate there, which suggests that one could refactor it.

We could minimise the boilerplate by putting the visual format strings in an array of strings and mapping over them with constraintsWithVisualFormat. That would give us an array of arrays of constraints, which we could then flatten using reduce, like so:

let visualConstraints = [
  "H:|-12-[lBtn(30)]-6-[title]-6-[rBtn(30)]-|",
  "V:|-10-[title(20)]", ...
]
let constraints = visualConstraints.map { 
  NSLayoutConstraint.constraintsWithVisualFormat($0, 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict)
}.reduce([], { $0 + $1 })
myView.addConstraints(constraints)
Which is somewhat better, though it can be improved. As of Xcode 6.3, arrays (and optionals) in Swift will have a new operation named flatMap, which combines the map and flatten steps. In short, where a map takes a container of some values of type A and a function that converts an A to a B and returns a container of the same number of values of type B, flatMap takes the same array and a function that converts an A to a container of zero or more Bs, and returns a container of some number of Bs. In any case, with flatMap, the above code reduces to:
let visualConstraints = [
  "H:|-12-[lBtn(30)]-6-[title]-6-[rBtn(30)]-|",
  "V:|-10-[title(20)]", ...
]
let constraints = visualConstraints.flatMap { 
  NSLayoutConstraint.constraintsWithVisualFormat($0, 
        options:NSLayoutFormatOptions(0), metrics:nil, views:viewsDict)
}
myView.addConstraints(constraints)
flatMap is a useful operation, and one whose uses one can see in many places. In general, whenever a process produces zero or more items of output for each input, one wants to use a flatMap to handle them. Swift is also introducing flatMap on Optionals (which, of course, may be seen as a container holding zero or one items of a type), where it can be used for chaining a number of functions which may or may not yield a value:
func getCurrentUserIcon(session: Session?) -> UIImage? {
  return getUser(session).flatMap { getIconForUser($0) }
}
This is a little like Swift's optional chaining, with the key difference that, while optional chaining is limited to calling the underlying objects' methods, flatMap can apply any expression yielding an Optional; a move away from the object-oriented paradigm of objects and methods towards more functional techniques.

Unsurprisingly, flatMap is much more common in functional programming. The presence of a flatMap operation is one of the defining criteria of a pattern known as the monad, which, in a functional paradigm, can be used to define everything from container types to asynchronous operations to ways of compartmentalising state in functions without side-effects, in a way that follows consistent laws. Languages like Haskell and Scala use monads extensively, defining the relevant types in a consistently monadic fashion. And while Swift is not a functional language per se, it has been speculated for a while that it may be moving in an increasingly functional direction (albeit perhaps sufficiently gradually as not to alienate old Objective C hands); the arrival of flatMap could be more grist to this mill.

autolayout functional programming ios monads swift 0

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

Your comment:


Please enter the text in the image above here: