Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Updating multiple view constraints at the same time

Is there a way to tell the auto layout engine, at runtime, that I'm going to change multiple constraints on a view at the same time? I keep running into a situation where the order in which I update the constraints matters because even though when all is said and done all of the constraint conditions are satisfied, changing the constant on one before the other throws a LAYOUT_CONSTRAINTS_NOT_SATISFIABLE exception (or whatever the exception is actually called...)

Here's an example. I create and add a view to my current view and set some constraints on it. I'm saving the leading and trailing edge constraints so I can change their constants later.

- (MyView*)createMyView
{
    MyView* myView = [[MyView alloc init];

    [myView setTranslatesAutoresizingMaskIntoConstraints:NO];
    [self.mainView addSubview:myView];

    NSDictionary* views = @{@"myView" : myView};
    NSLayoutConstraint* leading = [NSLayoutConstraint constraintWithItem:myView
                                                               attribute:NSLayoutAttributeLeading
                                                               relatedBy:NSLayoutRelationEqual
                                                                  toItem:self.view
                                                               attribute:NSLayoutAttributeLeading
                                                              multiplier:1.0f
                                                                constant:0];
    NSLayoutConstraint* trailing = [NSLayoutConstraint constraintWithItem:myView
                                                                attribute:NSLayoutAttributeTrailing
                                                                relatedBy:NSLayoutRelationEqual
                                                                   toItem:self.view
                                                                attribute:NSLayoutAttributeTrailing
                                                               multiplier:1.0f
                                                                 constant:0];
    myView.leadingConstraint = leading;
    myView.trailingConstraint = trailing;

    [self.view addConstraints:@[leading, trailing]];

    NSString* vflConstraints = @"V:|[myView]|";
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:vflConstraints
                                                                      options:NSLayoutFormatAlignAllCenterX
                                                                      metrics:nil
                                                                        views:views]];
    return myView;
}

When swipe gesture on the view fires off, I create a new view off to one side or the other depending on the direction of the swipe. I then update the constraints and animate so the new incoming view "pushes" the old view out of view.

- (void)transitionToCameraView:(MyView*)newCameraView
                swipeDirection:(UISwipeGestureRecognizerDirection)swipeDirection
{
    // Set new view off screen before adding to parent view
    float width = self.myView.frame.size.width;
    float height = self.myView.frame.size.height;
    float initialX = (swipeDirection == UISwipeGestureRecognizerDirectionLeft) ? width : -width;
    float finalX = (swipeDirection == UISwipeGestureRecognizerDirectionLeft) ? -width : width;
    float y = 0;

    [newView setFrame:CGRectMake(initialX, y, width, height)];
    [self.mainView addSubview:newView];

    self.myView.leadingConstraint.constant = finalX;
    self.myView.trailingConstraint.constant = finalX;

    // Slide old view out and new view in
    [UIView animateWithDuration:0.4f
                          delay:0.0f
                        options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionAllowUserInteraction
                     animations:^
            {
                [newView setFrame:self.myView.frame];
                [self.myView layoutIfNeeded];
            }
                     completion:^(BOOL finished)
            {
                [self.myView removeFromSuperview];
                self.myView = newView;
            }];
}

This works fine when the swipe direction is UISwipeGestureRecognizerDirectionLeft, but when it is UISwipeGestureRecognizerDirectionRight, the exception gets thrown at

    self.myView.leadingConstraint.constant = finalX;

If I check the direction and update the constraints in the reverse order, everything is fine and the exception does not get thrown:

if(swipeDirection == UISwipeGestureRecognizerDirectionLeft)
{
    self.myView.leadingConstraint.constant = finalX;
    self.myView.trailingConstraint.constant = finalX;
}
else
{
    self.myView.trailingConstraint.constant = finalX;
    self.myView.leadingConstraint.constant = finalX;
}

It DOES make sense to me WHY the exception is getting thrown, but it seems like changing multiple constraint parameters is something we should be able to do and let the auto layout engine know that we're going to be doing that, instead of it immediately trying to satisfy the constraint and throwing up.

Is there a way to tell the auto layout engine that I'm making changes to multiple constraints and then tell it to run again when I'm done, or am I stuck doing it this way? It seems really silly to me to have the order in which I update the constraints matter here when everything is actually going to be fine when I'm finished with the changes.

EDIT
Upon further investigation, I've discovered that the underlying reason WHY the order matters is that myView has a subview added to it that has a constraint specifying that it's leading edge is 10pt from myView's leading edge. If I make and adjustment to the new constant to account for this, there is no longer a constraint conflict and there is no exception.

self.myView.leadingConstraint.constant = finalX+25; // Just arbitrary number that happened to work
self.myView.trailingConstraint.constant = finalX;

The reason there is a problem is that the new constraint when swiping left-to-right collapses myView to have a width of 0. Obviously there is not enough room in a view of width 0 to add a 10pt leading edge constraint to a subview. Changing the constants in the reverse order, instead, first widens the view by increasing the trailing edge, then shrinks it back up again by decreasing the leading edge.

Even more reason to have a way to tell the auto layout system that we're going to make multiple changes.

like image 653
Jordan Avatar asked Jul 14 '14 22:07

Jordan


3 Answers

To update multiple constraints at the same time, override updateConstraints with your update logic and call setNeedsUpdateConstraints to trigger updates. From Apple's Auto Layout Guide > Advanced Auto Layout > Changing Constraints:

To batch a change, instead of making the change directly, call the setNeedsUpdateConstraints method on the view holding the constraint. Then, override the view’s updateConstraints method to modify the affected constraints.

It's not as straightforward to update constraints this way so I recommend that you read the docs carefully before going down this route.

like image 185
yood Avatar answered Sep 19 '22 12:09

yood


First, you seem to have over-constrained myView relative to its superview such that the constraints can't allow it to slide. You've pinned its leading and trailing edges plus, when adding the vertical constraint, you've pinned its center, too, by specifying NSLayoutFormatAlignAllCenterX.

That aside, perhaps the better way to think about it is that having two constraints that you have to update in sync suggests you're using the wrong constraints. You could have a constraint for the leading edge and then a constraint making myView's width equal to the width of self.view. That way, you only have to adjust the leading constraint's constant and both edges move as a consequence.

Also, you're using -setFrame:, which is a no-no with auto layout. You should be animating the constraints. You should set up constraints on newView such that its leading or trailing edge (as appropriate) equals the the neighboring edge of myView and its width equals that of self.view. Then, in the animation block, change the constant of the constraint determining the placement of myView (and, indirectly, newView) to slide them together. In the completion handler, install a constraint on newView to keep its leading edge coincident with self.view's, since it's the new myView.

BOOL left = (swipeDirection == UISwipeGestureRecognizerDirectionLeft);
float width = self.myView.frame.size.width;
float finalX = left ? -width : width;
NSString* layout = left ? @"[myView][newView(==mainView)]" : @"[newView(==mainView)][myView]";
NSDictionary* views = NSDictionaryOfVariableBindings(newView, myView, mainView);

[self.mainView addSubview:newView];
[self.mainView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:layout
                                                                      options:0
                                                                      metrics:nil
                                                                        views:views]];
[self.mainView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|newView|"
                                                                      options:0
                                                                      metrics:nil
                                                                        views:views]];
[self.mainView layoutIfNeeded];

// Slide old view out and new view in
[UIView animateWithDuration:0.4f
                      delay:0.0f
                    options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionAllowUserInteraction
                 animations:^
        {
            self.myView.leadingConstraint.constant = finalX;
            [self.myView layoutIfNeeded];
        }
                 completion:^(BOOL finished)
        {
            [self.myView removeFromSuperview];
            self.myView = newView;
            NSLayoutConstraint* leading = [NSLayoutConstraint constraintWithItem:newView
                                                                       attribute:NSLayoutAttributeLeading
                                                                       relatedBy:NSLayoutRelationEqual
                                                                          toItem:self.view
                                                                       attribute:NSLayoutAttributeLeading
                                                                      multiplier:1.0f
                                                                        constant:0];
            self.myView.leadingConstraint = leading;
            [self.mainView addConstraint:leading];
        }];

Edited to answer the question more directly: No, there's no way in the public API to batch constraint updates so that the engine doesn't check them one-at-a-time as they're added or mutated by setting their constant.

like image 33
Ken Thomases Avatar answered Sep 21 '22 12:09

Ken Thomases


Also, what you can do is to set one of the constraints as inactive, and activate it back again after you set everything right:

self.constraint.active = false;

like image 24
Dannie P Avatar answered Sep 21 '22 12:09

Dannie P