Using AutoLayout to stack within two Columns of va

2019-02-08 21:04发布

问题:

Targetting iOS 8.1

I am using AutoLayout to lay out a number of Labels in a TableCell. Some of those Labels are optional and some can wrap their Text. They are split across two "Columns", these columns are simply two UIViews in the TableCell's ContentView. My constraints are applied programatically.

SECOND UPDATE

Without SwiftArchitect's answer below I would not have solved this and have accepted his answer. However because mine is all in code, in a custom tablecell, I have also added a separate answer below

UPDATE

In an attempt to stop the labels from stretching to a size larger than they needed to be I had previously set the SetContentHuggingPriority and SetContentCompressionResistancePriority to 1000 as I belived this was the equivalent of saying "I want the Label to hug its content to its exact height and I do not want it to ever be compressed vertically" This request was clearly not being complied with by AutoLayout as you can see in the Red and Pink examples below.

this.notesLabel.SetContentHuggingPriority(1000, UILayoutConstraintAxis.Vertical);
this.notesLabel.SetContentCompressionResistancePriority(1000, UILayoutConstraintAxis.Vertical);

I removed the setting of these priorities and the labels are no longer being squashed which was my original issue. Of course now certain labels are stretched beyond the height they need to be.

  1. Why does removing the Hugging and Compression priorities fix my issue?
  2. How can I get the text in the red box (red box not part of the cell added later) to not expand without going back to my previous issue?

Here are a couple of screenshots of what it did look like when the Compression and Hugging priorities where set. The background colours are for debugging

The general problem was that the Containing View's (colored purple and red) were sizing themselves to the smaller of the two. As you can see in the top one "Priority 3" is being cut because the left column container doesn't need to be any higher.

In this next example there is no Priority label but the EventDate is being squashed.

回答1:

The following answer has been written and tested. It works properly on iPhone & iPad, portrait & landscape. The tallest column wins, and the other one just takes the space it needs. It can even be modified to vertically center objects if need be. It addresses the vertically clipped label concerns, as well as dynamic scaling.

Preliminary advice

  • Use a Storyboard if you can. You can test all your constraints visually with a state-of-the-art GUI.
  • Do not tinker with hugging, compression, or even UILabel height: let each label take the space it needs vertically, and only add top and side anchors
  • Use extra views as containers to define the width of each column. Use multiplier to get, say 2 thirds and 1 third.
  • Let these views calculate their ideal height by adding a single height constraint to the bottom of the lowest label (leftColumn.bottom Equal lowestLeftLabel.bottom)
  • Do not add or remove views dynamically ; rather, hide them so that they preserve the associated constraints.

Solution Description

For simplicity, I have created 2 subviews, 1 for each column, and positioned them side by side. They are anchored on top/left and top/right, their width is calculated, and their height is derived from their respective content(*).

The left and right subviews have a 1/2 multiplier, to which I added a constant of 2 pixels for margin. The labels inside these two columns are anchored left and right (leading space to container and trailing space to container), with an 8 pixels margin. This ensure no label ever bleeds beyond its column.

  1. Consider that the height of your UITableViewCell is the largest of the 2 inner columns. In other words, containerView.height >= left.height and containerView.height >= right.height.
  2. Ensure you are not deleting any of the invisible labels. view.hidden will not disrupt your constraints, and that is what you want.
  3. Anchor each UILabel left and right to container, the uppermost to the container as well, but every subsequent label.top should be anchored to the .bottom of one above it. This way, your content will flow. You can add margins if you want.

(*) The final key is to tie the height of each column with a constraint on the column to equal the .bottom of the lowest label for that column. In the example above, you can clearly see that the right column, with a blue background, is shorter than the left one.

While I see you wanted a solution in code, I created my example using a Storyboard in less than 15 minutes. It is not merely a concept, it is an actual implementation. It has exactly 0 lines of code, and works on all iOS devices. Incidentally, it has also 0 bugs.

List of All Constraints

Notice the >= sprinkled here and there. They are the key to making your columns independently tall.

The NSLayoutContraint are virtually identical for L and R.

Get the Storyboard here, and a detailed article there.



回答2:

I've accepted SwiftArchitect's answer however seeing as I was after a code based approach for a TableCell I will add a separate answer. Without his help I would not have been able to get this far.

I am using MVVMCross and Xamarin iOS and my TableCell inherits from MvxTableViewCell

Creation of SubViews

From the ctor of the Cell I create all the necessary UILabels and turn off AutoResizingMasks by setting view.TranslatesAutoresizingMaskIntoConstraints = false

At the same time I create two UIViews leftColumnContainer and rightColumnContainer. These again TranslatesAutoresizingMaskIntoConstraints set to false.

The relevant labels are added as subviews to the leftColumnContainer and rightColumnContainer UIViews. The two containers are then added as SubViews to the TableCell's ContentView

this.ContentView.AddSubviews(this.leftColumnContainer, this.rightColumnContainer);
this.ContentView.TranslatesAutoresizingMaskIntoConstraints = true;

The UILabels are all data bound via an MVVMCross DelayBind call

Setting Layout Constraints (UpdateConstraints())

The layout of the TableCell is conditional on the data for the cell with 5 of the 8 labels being optional and 4 of the 8 needing to support wrapping of text

The First thing I do is pin the Top and Left of the leftColumnContainer to the TableCell.ContentView. Then the Top and Right of the 'rightColumnContainer' to the TableCell.ContentView. The design requires the right column to be smaller than the left so this is done using scaling. I am using FluentLayout to apply these constraints

this.ContentView.AddConstraints(
                this.leftColumnContainer.AtTopOf(this.ContentView),
                this.leftColumnContainer.AtLeftOf(this.ContentView, 3.0f),
                this.leftColumnContainer.ToLeftOf(this.rightColumnContainer),
                this.rightColumnContainer.AtTopOf(this.ContentView),
                this.rightColumnContainer.ToRightOf(this.leftColumnContainer),
                this.rightColumnContainer.AtRightOf(this.ContentView),
                this.rightColumnContainer.WithRelativeWidth(this.ContentView, 0.35f));

The calls to ToLeftOf and ToRight of are laying the right edge of the Left Column and the left edge of the Right Column next to each other

A key piece of the solution that came from SwiftArchitect was to set the height of the TableCell's ContentView to >= to the height of the leftColumnContainer and the rightColumnContainer. It wasn't so obvious how to do these with FluentLayout so they are "longhand"

this.ContentView.AddConstraint(
              NSLayoutConstraint.Create(this.ContentView, NSLayoutAttribute.Height, NSLayoutRelation.GreaterThanOrEqual, this.leftColumnContainer, NSLayoutAttribute.Height, 1.0f, 5.0f));

            this.ContentView.AddConstraint(
              NSLayoutConstraint.Create(this.ContentView, NSLayoutAttribute.Height, NSLayoutRelation.GreaterThanOrEqual, this.rightColumnContainer, NSLayoutAttribute.Height, 1.0f, 5.0f));

I then constraint the top, left and right of the first label in each column to the column container. Here is an example of from the first label in the left column

this.leftColumnContainer.AddConstraints(
                this.categoryLabel.AtTopOf(this.leftColumnContainer, CellPadding),
                this.categoryLabel.AtRightOf(this.leftColumnContainer, CellPadding),
                this.categoryLabel.AtLeftOf(this.leftColumnContainer, CellPadding));

For each of the labels that are optional I first check the MVVMCross DataContext to see if they are visible. If they are visible similar constraints for Left, Top and Right are applied with the Top being constrained to the Bottom of the label above. If they are not visible the are removed from the View like so

this.bodyText.RemoveFromSuperview();

If you are wondering how these cells are going to work with iOSs Cell Reuse I will cover that next.

If the label is going to be the last label in the column (this is dependant on the data) I apply the other key learning from SwiftArcthiect's answer

Let [the columns] calculate their ideal height by adding a single height constraint to the bottom of the lowest label (leftColumn.bottom Equal lowestLeftLabel.bottom)

Dealing with cell Reuse

With such a complicated set of Constraints and many optional cells I did not want to have to reapply the constraints everytime the cell was reused with potentially different optional labels. To do this I am building and setting the reuse identifier at runtime.

The TableSource inherits from MvxTableViewSource. In the overridden GetOrCreateCellFor I check for a specific reuseIdentifier (normal use) and if so call DequeueReusableCell however in this instance I defer to a routine encapsulated in the custom Cell class that knows how to be build a data specific id

protected override UITableViewCell GetOrCreateCellFor(UITableView tableView, NSIndexPath indexPath, object item)
        {
            MvxTableViewCell cell;

            if (this.reuseIdentifier != null)
            {
                cell = (MvxTableViewCell)tableView.DequeueReusableCell(this.reuseIdentifier);    
            }
            else
            {
                // No single reuse identifier, defer to the cell for the identifer
                string identifier = this.itemCell.GetCellIdentifier(item);

                if (this.reuseIdentifiers.Contains(identifier) == false)
                {
                    tableView.RegisterClassForCellReuse(this.tableCellType, identifier);
                    this.reuseIdentifiers.Add(identifier);
                }

                cell = (MvxTableViewCell)tableView.DequeueReusableCell(identifier);    
            }

            return cell;
        }

and the call to build the id

public string GetCellIdentifier(object item)
        {
            StringBuilder cellIdentifier = new StringBuilder();

            var entry = item as EntryItemViewModelBase;

            cellIdentifier.AppendFormat("notes{0}", entry.Notes.HasValue() ? "yes" : "no");
            cellIdentifier.AppendFormat("_body{0}", !entry.Body.Any() ? "no" : "yes");
            cellIdentifier.AppendFormat("_priority{0}", entry.Priority.HasValue() ? "yes" : "no");
            cellIdentifier.AppendFormat("_prop1{0}", entry.Prop1.HasValue() ? "yes" : "no");
            cellIdentifier.AppendFormat("_prop2{0}", entry.Prop2.HasValue() ? "yes" : "no");
            cellIdentifier.AppendFormat("_warningq{0}", !entry.IsWarningQualifier ? "no" : "yes");
            cellIdentifier.Append("_MEIC");

            return cellIdentifier.ToString();
        }


回答3:

First of all we should not play around wit content-Hugging or compression priority unless there is a situation where you need to change with no option left. The default 250:750 ratio given by apple will suit 90% of the scenario, if auto layout is properly configured. Only in rare cases where there is a conflict for the engine to resize the view based on the fulfilled constraints, we should change hugging/compression priorities.

Your original issue is your labels are being squashed. The labels by default enables intrinsic content size of system, where the engine will decide the label's width and height by default depending upon the labels content and text size. So if you decide your label not to extend the view, then you should set trailing constraint from your right side of your label to the view. In general it will be set to “Equals” property which will not suit our requirement because our label relayed on intrinsic property and we should not provide a standard width to the label. Hence instead of ‘Equals’ it should be ‘Greater than or equal to’ property to the trailing property.

In your scenario, you should not fix height constraint for labels and enable word-wrap feature along 'with number of line’ property for any required label of two line.

you are aware that you should always need to display ‘Yellow label’, ‘green label’ and ‘violet label’ in the right side view, irrespective of the left view has one or two line in the ‘red label’.

So fix a static height for the cell. In right hand side view, Fix top constraint for the ‘orange' label, and bottom constraint for the ‘yellow’ label. So that centre ‘red’ label will get a explicit height which can accommodate one/two lines according to the requirement. And give sufficient constraints to the right side view to fulfil your requirement.

If this is not solving you issue or any discussion upon my solution, comment below.