uitableviewuiscrollviewuipopovercontrolleruicontainerviewcontainer-view

Autolayout: How to compress a complex popover


If you like to try the source code (which you are very welcome to do), have a look at my Bitbucket repository.

I have a popover dialogue that shows a list of settings. These settings a listed inside multiple UITableViews. The UITableViews shall not be scrollable, for the overall settings view already is. Furthermore, the popover dialogue shall take as much screen vertically as it needs but shall be horizontally compressed.

Thus, I conceived the following structure:

UIView => MySettingsViewController
- UIScrollView
  - UIView (Content View)
    - Container View1
      - UITableView (embedded) => MyTableViewController
    - Container View2
      - UITableView (embedded)

The structure is assembled via Interface Builder and Autolayout is used for the sizing.

I have both the Scroll View, the Content View (I started with just one) and the Container View to their respective superviews (or layout guides). I constrained the size of the content view in the following manner:

contentView.width == (topmost) UIView.width
contentView.height == 200 // removed at build time

Additionally, I set the size of the table view to its content size, because otherwise the popover appears to be empty:

class MyTableViewController: UITableViewController {
    override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        // this is Cartography syntax - the intention should be clear
        layout(view, replace: ConstraintGroup()) { [unowned self] view in
            view.width == self.tableView.contentSize.width
            view.height == self.tableView.contentSize.height
        }
        view.setNeedsLayout()
    }
} 

The settings popover is filled with content, but its size is not quite right:

enter image description here

To fix this, I tried the following approach which does not work:

class MySettingsViewController: UIViewController {
    override var preferredContentSize: CGSize {
        get {
            let compressedSize = view.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
            // this is always (0, 0) because the subviews are not resized, yet 
            return compressedSize 
        }
        set {
            super.preferredContentSize = newValue
        }
    }
}

To conclude: The compression does not work.


Solution

  • So I just fixed the problem myself as you can see when looking at the Bitbucket repository.

    The layout is now fixed both in MyTableViewController and MySettingsViewController. The former one now looks like this:

    class MyTableViewController: UITableViewController {
    
        var heightConstraint: NSLayoutConstraint?
        var tableViewEdgesConstraints: [NSLayoutConstraint]?
    
        override func viewWillAppear(animated: Bool) {
            super.viewWillAppear(animated)
            if let container = tableView.superview where tableViewEdgesConstraints == nil {
                layout(tableView, container, replace: ConstraintGroup()) { [unowned self] tableView, container in
                    self.tableViewEdgesConstraints = tableView.edges == inset(container.edges, 0)
                }
            }
        }
    
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
    
            if let heightConstraint = heightConstraint {
                if Int(heightConstraint.constant) != Int(tableView.contentSize.height) {
                    heightConstraint.constant = self.tableView.contentSize.height
                }
            } else {
                layout(view, replace: ConstraintGroup()) { [unowned self] view in
                    if (self.tableView.contentSize.height > 0) {
                        self.heightConstraint = view.height == self.tableView.contentSize.height
                    }
                }
            }
        }
    } 
    

    So basically, I constraint the height of the table to its content's height and change the constraint if the content's height changes. This is done as soon as the table is laid out. Furthermore, the nested table view is pinned by its edges to the edges of the container view. I think that this is mandatory because I could not find out how to constrain two views of different scenes right in Interface Builder.

    In MySettingsViewController the scrollview's size is set to the size of the content view's frame (which is accessible via an outlet) as soon as this size is known. Furthermore, to make the popover compress, the preferredContentSize of the settings controller is adapted accordingly, when the height changes (if you omit the condition you might get yourself in a layout endless loop. Furthermore I did 3 things to make it possible to have a navigation controller wrapped around MySettingsViewController:

    1. The width of the popover is set to a fixed value (otherwise it would sometimes expand to the full width).
    2. The presentedViewController's preferredContentSize needs to be set equally.
    3. I had to set the insets of the scrollView to 0 to avoid an ugly vertical offset - this solution is sub-optimal because it breaks the scroll view experience a bit. But it works.

    Here is the code:

    class MySettingsViewController: UIViewController {
    
        @IBOutlet weak var contentView: UIView!
        @IBOutlet weak var scrollView: UIScrollView!
    
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
    
            scrollView.contentSize = contentView.frame.size
    
            if (preferredContentSize.height != scrollView.contentSize.height) {
                let newSize = CGSize(width: 400, height: scrollView.contentSize.height)
                preferredContentSize = newSize
                presentingViewController?.presentedViewController?.preferredContentSize = newSize
                scrollView.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)
            }
        }
    }
    

    And this is the result:

    enter image description here