Andrei Herford
Andrei Herford

Reputation: 18765

How to move all subviews to a new parentView while keeping all constraints intact?

I would like to programmatically move all subviews of a UIView to another UIView while keeping all constraints between the subviews to each other and to the (new) parent intact.

What is the "correct" way to do this?

My current solution simply loops through subviews and all constraints of the current parent view and checks if it either between the parent and the subview or between two subviews. Matching constraints are store and applied to the new parent:

var constraints = [NSLayoutConstraint]()
for subview in oldParent.subviews {
    for constraint in oldParent.constraints {
        if let newConstraint = moveConstraint(constraint, ofView: subview, fromView: oldParent, toView: newParent) {
            let active = oldConstraint.isActive
            oldParent.removeConstraint(constraint)

            newConstraint.isActive = active
            constraints.append(newConstraint)
        }
    }

    newParent.addSubview(subview)
}

newParent.addConstraints(constraints)


func moveConstraint(_ constraint: NSLayoutConstraint, ofView view: UIView, fromView prevSuperview: UIView, toView newSuperview: UIView) -> NSLayoutConstraint? {
    // Check prevSuperview + Layout Guides
    var prevSuperviewIsFirstItem: Bool = constraint.firstItem === prevSuperview
    if !prevSuperviewIsFirstItem {
        for layoutGuid in prevSuperview.layoutGuides {
            if constraint.firstItem === layoutGuid {
                prevSuperviewIsFirstItem = true
                break
            }
        }
    }
    
    var prevSuperviewIsSecondItem: Bool = constraint.secondItem === prevSuperview
    if !prevSuperviewIsSecondItem {
        for layoutGuid in prevSuperview.layoutGuides {
            if constraint.secondItem === layoutGuid {
                prevSuperviewIsSecondItem = true
                break
            }
        }
    }
    
    // Move constraints between prevSuperview + View
    var newConstraint: NSLayoutConstraint? = nil 
    if prevSuperviewIsFirstItem && constraint.secondItem === view {
        newConstraint = NSLayoutConstraint(item: newSuperview, attribute: constraint.firstAttribute, relatedBy: constraint.relation, toItem: view, attribute: constraint.secondAttribute, multiplier: constraint.multiplier, constant: constraint.constant)
    } else if prevSuperviewIsSecondItem && constraint.firstItem === view {
        newConstraint = NSLayoutConstraint(item: view, attribute: constraint.firstAttribute, relatedBy: constraint.relation, toItem: newSuperview, attribute: constraint.secondAttribute, multiplier: constraint.multiplier, constant: constraint.constant)
    }

    if let newConstraint = newConstraint {
        // handle newConstraint.isActive only after oldConstraint has
        // been removed to avoid conflicts

        newConstraint.identifier = constraint.identifier
        newConstraint.priority = constraint.priority
        newConstraint.shouldBeArchived = constraint.shouldBeArchived
        return newConstraint
    }
    

    // Move constraints between view and other subview
    if constraint.firstItem === view || constraint.secondItem === view {
        return constraint
    }
    
    return nil
}

While this seems to work fine according to my tests, I would like to make sure that this really correct. Handling constraints is always a difficult issue which can easily lead to errors at some time in the future.

So: Is the copy process enough to transfer the subviews and their constraints correctly? Or is there anything else so consider? Or is there a better way?

EDIT: Context

One use case would be a UICollectionViewCell with a card like layout. I followed a hint in a different answer to achieve this by adding two subviews to the contentView which act as wrapper and apply the shadow and round corners through their layer properties. The goal was to create a UICollectionViewCell subclass which automatically adds these wrapper subviews in awakeFromNib() and moves all subviews of the contentView to this wrapper view.

Of course one could easily add these wrappers in InterfaceBuilder but when handling a lot of different cells in different projects a general approach which handles this automatically would be a good solution.

Upvotes: 3

Views: 470

Answers (3)

john07
john07

Reputation: 560

In order to avoid a crash in case of a conflict of constraints, they must be activated at the end

    var constraints = [NSLayoutConstraint]()
    var deferred:[NSLayoutConstraint] = []
    for subview in oldParent.subviews {
        for constraint in oldParent.constraints {
            if let newConstraint = moveConstraint(constraint, ofView: subview, fromView: oldParent, toView: newParent) {
                let active = constraint.isActive
                oldParent.removeConstraint(constraint)
                if active {
                    deferred.append(newConstraint)
                }
                //newConstraint.isActive = active
                constraints.append(newConstraint)
            }
        }
        newParent.addSubview(subview)
    }
    
    newParent.addConstraints(constraints)
    
    deferred.forEach{$0.isActive = true}

Upvotes: 0

Tarun Tyagi
Tarun Tyagi

Reputation: 10112

Disclaimer : This is in NO WAY a complete answer.

Here are a few things that I noticed -


#1 Access to these properties is not recommended. Use the firstAnchor and secondAnchor properties instead.

     /* accessors
     firstItem.firstAttribute {==,<=,>=} secondItem.secondAttribute * multiplier + constant
     Access to these properties is not recommended. Use the `firstAnchor` and `secondAnchor` properties instead.
     */
    unowned(unsafe) open var firstItem: AnyObject? { get }

    unowned(unsafe) open var secondItem: AnyObject? { get }

Here's the supporting debug output for why firstItem & secondItem values are NOT always UILayoutGuide instances.

  ▿ Optional<AnyObject>
    - some : <_UILayoutGuide: 0x10b89b810; frame = (0 0; 0 0); hidden = YES; layer = <CALayer: 0x2827d2dc0>>

  ▿ Optional<AnyObject>
    - some : <UILayoutGuide: 0x281f9b2c0 - "UIViewSafeAreaLayoutGuide", layoutFrame = {{0, 0}, {375, 603}}, owningView = <UIView: 0x10b89b6a0; frame = (0 0; 375 603); autoresize = W+H; layer = <CALayer: 0x2827d2da0>>>

  ▿ Optional<AnyObject>
    - some : <UIScrollView: 0x10b19b800; frame = (0 0; 375 603); clipsToBounds = YES; autoresize = RM+BM; gestureRecognizers = <NSArray: 0x2829f8000>; layer = <CALayer: 0x2827ba1c0>; contentOffset: {0, 145}; contentSize: {375, 748}; adjustedContentInset: {0, 0, 0, 0}>

  ▿ Optional<AnyObject>
    - some : <UIView: 0x10b89b6a0; frame = (0 0; 375 603); autoresize = W+H; layer = <CALayer: 0x2827d2da0>>

#2 The current code does NOT account for priority value

open var priority: UILayoutPriority

#3 The current code does NOT account for shouldBeArchived value

open var shouldBeArchived: Bool

Also I didn't know about it until today - and I'm not sure what's the correct way to handle this.


#4 The current code does NOT account for firstAnchor & secondAnchor values

     /* accessors
     firstAnchor{==,<=,>=} secondAnchor * multiplier + constant
     */
    @available(iOS 10.0, *)
    @NSCopying open var firstAnchor: NSLayoutAnchor<AnyObject> { get }

    @available(iOS 10.0, *)
    @NSCopying open var secondAnchor: NSLayoutAnchor<AnyObject>? { get }

#5 The current code does NOT account for isActive value

open var isActive: Bool

#6 The current code does NOT account for identifier value

open var identifier: String?

If you are working this out for your own app and can make sure that you are not using some of these values on the originally created constraints, that should be fine.

In case you are planning to put this into a library that will be used in other apps, that opens it up for all of the current issues + future changes / issues.


I would HIGHLY RECOMMEND that you should try to avoid this if possible and rethink what should be done instead to achieve the same goal. Maybe you want to have a wrapper UIView instance that has all of these subviews and this wrapper UIView instance is transferred to another view. This should minimize the number of constraints this code has to touch/recreate.


In any case - you MUST do following (if you are planning to go ahead with this).

  1. Copy firstAnchor & secondAnchor values from old to new constraint. - NOT POSSIBLE, these are GET only, see in the comments.
  2. Copy priority value from old to new constraint.
  3. Copy identifier value from old to new constraint.
  4. Copy shouldBeArchived value from old to new constraint.
  5. DO NOT ACTIVATE any constraint with isActive = false originally.

Upvotes: 1

Ayrton
Ayrton

Reputation: 534

@dfd is correct, views are heavily linked to each other when it comes to constraints. If you remove a view from its superview then those constraints will be broken, but if you had a view within a view and only change the parent view then the subview would keep its constraints. wow, that sounds a lot more confusing than it actually is. But yea, in general, the way you're doing it is fine, I can't think of a better way off the top of my head, maybe make an extension that you could call on a UIView? something like view.moveTo(newView)?

Upvotes: 1

Related Questions