Offscreen UITableViewCells (for size calculations) not respecting size class?
Asked Answered
G

2

15

I am using Auto Layout and size classes inside a UITableView with cells that self-size based on their content. For this I'm using the method where for each type of cell, you keep an offscreen instance of that cell around and use systemLayoutSizeFittingSize on that to determine the correct row height - this method is explained wonderfully in this StackOverflow post and elsewhere.

This worked great until I started using size classes. Specifically I've defined different constants on the margin constraints for text in Regular Width layouts, so there is more whitespace around the text on iPad. This gives me the following results.

before and after

It appears that the new set of constraints is being honored (there is more whitespace), but that the row height calculation still returns the same value as it would for a cell that didn't apply the size class-specific constraints. Some part of the layout process in the offscreen cell is not taking the window's size class into account.

Now I figured that that's probably because the offscreen view has no superview or window, and as such it doesn't have any size class traits to refer to at the point the systemLayoutSizeFittingSize call occurs (even though it does seem to use the adjusted constraints for the margins). I now work around this by adding the offscreen sizing cell as a subview of the UIWindow after it's created, which gives the desired result:

fixed

Here's what I'm doing in code:

func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    let contentItem = content[indexPath.item]

    if let contentType = contentItem["type"] {
        // Get or create the cached layout cell for this cell type.
        if layoutCellCache.indexForKey(contentType) == nil {
            if let cellIdentifier = CellIdentifiers[contentType] {
                if var cachedLayoutCell = dequeueReusableCellWithIdentifier(cellIdentifier) as? UITableViewCell {                        
                    UIApplication.sharedApplication().keyWindow?.addSubview(cachedLayoutCell)
                    cachedLayoutCell.hidden = true
                    layoutCellCache[contentType] = cachedLayoutCell
                }
            }
        }

        if let cachedLayoutCell = layoutCellCache[contentType] {
            // Configure the layout cell with the requested cell's content.
            configureCell(cachedLayoutCell, withContentItem: contentItem)

            // Perform layout on the cached cell and determine best fitting content height.
            cachedLayoutCell.bounds = CGRectMake(0.0, 0.0, CGRectGetWidth(tableView.bounds), 0);
            cachedLayoutCell.setNeedsLayout()
            cachedLayoutCell.layoutIfNeeded()

            return cachedLayoutCell.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
        }
    }

    fatalError("not enough information to determine cell height for item \(indexPath.item).")
    return 0
}

Adding views to the window that aren't ever supposed to be drawn seems like a hack to me. Is there a way to have UIViews fully adopt the window's size class even when they're not currently in the view hierarchy? Or is there something else I'm missing? Thanks.

Ground answered 1/1, 2015 at 22:35 Comment(0)
I
9

Update on Dec 2015:

Apple now discourages overriding -traitCollection. Please consider using other workarounds. From the doc:

IMPORTANT

Use the traitCollection property directly. Do not override it. Do not provide a custom implementation.


Original Answer:

The existing answer is great. It explained that the problem is that:

The proposed workaround is to temporarily add the cell to the table view. However, this does NOT work if we are in, say, -viewDidLoad, in which the traitCollection of the table view, or the view of the view controller, or even the view controller itself, is not valid yet.

Here, I propose another workaround, which is to override traitCollection of the cell. To do so:

  1. Create a custom subclass of UITableViewCell for the cell (which you probably did already).

  2. In the custom subclass, add a - (UITraitCollection *)traitCollection method, which overrides the getter of the traitCollection property. Now, you can return any valid UITraitCollection you like. Here's a sample implementation:

    // Override getter of traitCollection property
    // https://mcmap.net/q/23787/-offscreen-uitableviewcells-for-size-calculations-not-respecting-size-class
    - (UITraitCollection *)traitCollection
    {
        // Return original value if valid.
        UITraitCollection* originalTraitCollection = [super traitCollection];
        if(originalTraitCollection && originalTraitCollection.userInterfaceIdiom != UIUserInterfaceIdiomUnspecified)
        {
            return originalTraitCollection;
        }
    
        // Return trait collection from UIScreen.
        return [UIScreen mainScreen].traitCollection;
    }
    

    Alternatively, you can return a suitable UITraitCollection created using any one of its create methods, e.g.:

    + (UITraitCollection *)traitCollectionWithDisplayScale:(CGFloat)scale
    + (UITraitCollection *)traitCollectionWithTraitsFromCollections:(NSArray *)traitCollections
    + (UITraitCollection *)traitCollectionWithUserInterfaceIdiom:(UIUserInterfaceIdiom)idiom
    + (UITraitCollection *)traitCollectionWithHorizontalSizeClass:(UIUserInterfaceSizeClass)horizontalSizeClass
    + (UITraitCollection *)traitCollectionWithVerticalSizeClass:(UIUserInterfaceSizeClass)verticalSizeClass
    

    Or, you can even make it more flexible by doing this:

    // Override getter of traitCollection property
    // https://mcmap.net/q/23787/-offscreen-uitableviewcells-for-size-calculations-not-respecting-size-class
    - (UITraitCollection *)traitCollection
    {
        // Return overridingTraitCollection if not nil,
        // or [super traitCollection] otherwise.
        // overridingTraitCollection is a writable property
        return self.overridingTraitCollection ?: [super traitCollection];
    }
    

This workaround is compatible with iOS 7 because the traitCollection property is defined in iOS 8+, and so, in iOS 7, no one will call its getter, and thus our overriding method.

Inna answered 14/2, 2015 at 8:52 Comment(5)
Nice idea. Wish I'd thought of that in my answer 8^). I guess the next step is to do this as a class extension if possible: UITableViewController+TraitCollectionOverride.hYellowthroat
This is helpful. Also any constraints configured based on different size classes in the storyboard are also wrong. You always get constraints and their values set for the Any:Any size class. This does indeed seem to be a bug or at least a huge oversight that there is no way to get a correctly configured instance of a cell prototype from a storyboard that isn't being displayed in the tableView.Clint
It does not seem to work for me. I still get Any Any constraints from xib file. Does it work for you guys?Riyal
It's a nice idea to override the traitCollection property of a view. However, this is strongly discouraged by Apple: Use the traitCollection property directly. Do not override it. Do not provide a custom implementation.Bryan
I think it's intended by Apple to be this way in order to prevent developers from misusing size classes. If they would allow developers to change the traitCollection of a view it would mean that you could change a view's size class while it's being displayed on a regular width device to a size class with compact width which would most likely mess around with the OS. It would be kind of difficult for the system to handle a device rotation from portrait to landscape mode, for example, because it's not clear how the view should react in order to adapt to the orientation change.Bryan
Y
5

I spent days on this after moving to using size classes to make changing font size easier on iPad versus iPhone etc.

The root of the issue seems to be that dequeueReusableCellWithIdentifier: returns a cell which has no superview from which it obtains its UITraitCollection. dequeueReusableCellWithIdentifier:forIndexPath:, on the other hand, returns a cell whose superview is a UITableViewWrapperView.

I have raised a bug report with Apple as they have not extended this method to support size classes; it seems not documented that how to deal with size classes on iOS7. As you are sending a message to a UITableView asking for a cell, it should return one which reflects the size class of the table you are sending the message to. This is the case for dequeueReusableCellWithIdentifier:forIndexPath:.

I have also noticed that, when trying to use the new auto layout mechanism, you often need to reload the table in viewDidAppear: to get the new mechanism to work properly. Without this I see the same issue I have using the iOS7 approach.

It does not seem possible to use auto layout on iOS8 and the old mechanism for iOS7 from the same code, as far as I can tell.

For now, I have had to resort to working around the problem by adding the prototype cell as a subview of the table, doing the size calculation, then removing it:

UITableViewCell *prototype=nil;
CGFloat prototypeHeight=0.0;

prototype=[self.tableView dequeueReusableCellWithIdentifier:@"SideMenuCellIdentifier"];

// Check for when the prototype cell has no parent view from 
// which to inherit size class related constraints.
BOOL added=FALSE;
if (prototype.superview == nil){
   [self.tableView addSubview:prototype];
   added=TRUE;
}

<snip ... Setup prototype cell>

[prototype setNeedsLayout];
[prototype layoutIfNeeded];
CGSize size = [prototype.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
prototypeHeight=size.height+1; // Add one for separator

// Remove the cell if added. Leaves it when in iOS7.
if (added){
  [prototype removeFromSuperview];
}

Size class related settings seem to be controlled via a UITraitCollection which is a read only property of a UIViewController. For iOS7 backward compatibility, this seems to be handled by the build system as a work around with some restrictions. i.e. on iOS7 you cannot access the traitCollection property, but you can in iOS8.

Given the tight coupling with the view controller from the storyboard and how backward compatibility works, it looks like the prototype cell will have to be in the hierarchy of the view controller you defined in Xcode.

There is a discussion on this here:
How can Xcode 6 adaptive UIs be backwards-compatible with iOS 7 and iOS 6?

Yellowthroat answered 6/1, 2015 at 12:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.