Search code examples
iosanimationuiviewautolayout

AutoLayout animate sliding view out and sliding other views over to take its place


This is a follow-up question to this question.

Following the solution proposed by @DonMag, where the horizontal centers of the views are constrained using AutoLayout, I have the following class and everything is working fine but I am trying to add animation to the insertion and removal of subviews, but I am running into issues getting it to work properly.

Here are some screenshots of what is happening now. For some reason, the shelf view moves to the left about a half an item width, I think. But I don't know why it is doing that. Is the animation code I have correct or is there a better way to handle animating the AutoLayout changes?

The animation code I am asking about is in the removeArrangedSubview:animated: method. It first calls layoutIfNeeded to flush any changes first before starting the animation. Then, inside the animation, it applies the changes to the constraints (it updates the centerX constraints of the remaining subviews) and then layoutIfNeeded is called again to apply the AutoLayout changes and animate those.

Is that not the correct way to animate these changes? But here are some screenshots showing what happens. The first screenshot shows it with seven items and everything is working fine.

enter image description here

This second screenshot shows when the view jumps to the left for some reason. I don't know what is causing this to happen. All of the subviews have their centerX constraints deactivated.

enter image description here

The rest of the screenshots show the other subviews animating back into position. But ideally what would happen is that if you have five subviews and you remove the middle subview, then the two to the left of that subview would stay where they are. Only the two to the right would animate and slide over to fill the space where the middle subview was. enter image description here

enter image description here

enter image description here

Questions

  1. Does anyone know what I am doing wrong in my animation code? Why is the parent view jumping to the left to begin with? I am just trying to slide out the view that is being removed and then at the same time slide over the items that are to the right of the removed view. That is all I am trying to do.

Code

Here is my code. The animation code is in the removeArrangedSubview:animated: method.

MyShelf.h

#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSInteger, MyShelfItemShape) {
    MyShelfItemShapeNone = 0,
    MyShelfItemShapeCircular
};

@interface MyShelf : UIView

@property (copy, nonatomic, readonly) NSArray<__kindof UIView *> *arrangedSubviews;

@property (assign, nonatomic) CGSize itemSize;
@property (assign, nonatomic) MyShelfItemShape itemShape;
@property (strong, nonatomic) UIColor *itemBorderColor;
@property (assign, nonatomic) CGFloat itemBorderWidth;

@property (assign, nonatomic) CGFloat preferredMinimumSpacing;
@property (assign, nonatomic) CGFloat preferredMaximumSpacing;

#pragma mark - Managing the Horizontal Order of Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex inFront:(BOOL)inFront animated:(BOOL)animated;
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated;
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex;

- (void)addArrangedSubview:(UIView *)view inFront:(BOOL)inFront animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view;

- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)removeArrangedSubview:(UIView *)view;

#pragma mark - Managing the Vertical Order of Arranged Subviews
- (void)bringArrangedSubviewToFront:(UIView *)view;

@end

NS_ASSUME_NONNULL_END

MyShelf.m

#import "MyShelf.h"

@interface MyShelf ()

@property (strong, nonatomic) UIView *positionView;
@property (strong, nonatomic) UIView *framingView;
@property (strong, nonatomic) NSLayoutConstraint *framingViewTrailingConstraint;

@property (strong, nonatomic, readwrite) NSMutableArray<__kindof UIView *> *mutableArrangedSubviews;

@end

@implementation MyShelf

- (instancetype)init {
    return [self initWithFrame:CGRectZero];
}

- (instancetype)initWithCoder:(NSCoder *)coder {
    if (self = [super initWithCoder:coder]) {
        [self initialize];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        [self initialize];
    }
    return self;
}

- (void)initialize {
    
    
    
    
//    self.previousFrame = CGRectZero;
//    self.spacing = -10;
//    self.axis = UILayoutConstraintAxisHorizontal;
//    self.alignment = UIStackViewAlignmentCenter;
//    self.distribution = UIStackViewDistributionFill;
    
    
    //self.itemSize = CGSizeZero;
    self.itemSize = CGSizeMake(35, 35);
    self.itemShape = MyShelfItemShapeNone;
    self.itemBorderColor = [UIColor blackColor];
    self.itemBorderWidth = 1.0;
    self.mutableArrangedSubviews = [[NSMutableArray alloc] init];
    self.directionalLayoutMargins = NSDirectionalEdgeInsetsZero;
    
    //framingView will match the bounds of the items and it will look like their superview,
    //but it is not the superview of the items
    [self addSubview:self.framingView];
    
    //positionView is used for the item position constraints but it is not seen
    [self addSubview:self.positionView];
    
    [NSLayoutConstraint activateConstraints:@[
        //center the position view vertically with no height
        [self.positionView.centerYAnchor constraintEqualToAnchor:self.layoutMarginsGuide.centerYAnchor],
        [self.positionView.heightAnchor constraintEqualToConstant:0],
        
        //both the leading and trailing edges of the position view should be inset by 1/2 of the item width
        [self.positionView.leadingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.leadingAnchor constant:self.itemSize.width / 2.0],
        [self.positionView.trailingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.trailingAnchor constant:-self.itemSize.width / 2.0],
        
        
        //framing view leading is at the positioning view leading mius 1/2 of the item width
        [self.framingView.leadingAnchor constraintEqualToAnchor:self.positionView.leadingAnchor constant:-self.itemSize.width / 2.0],
        
        [self.framingView.topAnchor constraintEqualToAnchor:self.layoutMarginsGuide.topAnchor],
        [self.framingView.bottomAnchor constraintEqualToAnchor:self.layoutMarginsGuide.bottomAnchor]
    ]];
}



- (CGSize)intrinsicContentSize {
    return CGSizeMake(self.mutableArrangedSubviews.count * self.itemSize.width, self.itemSize.height);
}


- (void)updateHorizontalPositions {
    if (self.mutableArrangedSubviews.count == 0) {
        //no items, so all we have to do is to update the framing view
        self.framingViewTrailingConstraint.active = NO;
        self.framingViewTrailingConstraint = [self.framingView.trailingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.leadingAnchor];
        self.framingViewTrailingConstraint.active = YES;
        return;
    }
    
    //clear the existing centerX constraints
    for (NSLayoutConstraint *constraint in self.positionView.constraints) {
        if (constraint.firstAttribute == NSLayoutAttributeCenterX || constraint.firstAttribute == NSLayoutAttributeCenterXWithinMargins) {
            constraint.active = NO;
        }
    }
    
    //the first item will be equal to the positionView's leading
    UIView *currentItem = [self.mutableArrangedSubviews firstObject];
    [NSLayoutConstraint activateConstraints:@[
        [currentItem.centerXAnchor constraintEqualToAnchor:self.positionView.leadingAnchor]
    ]];
    
    
    
    // percentage for remaining item spacing
    //  examples:
    //      we have 3 items
    //          item 0 centerX is at leading
    //          item 1 centerX is at 50%
    //          item 2 centerX is at 100%
    //      we have 4 items
    //          item 0 centerX is at leading
    //          item 1 centerX is at 33.33%
    //          item 2 centerX is at 66.66%
    //          item 3 centerX is at 100%
    
    CGFloat percent = 1.0 / (CGFloat)(self.mutableArrangedSubviews.count - 1);
    
    UIView *previousItem;
    for (int x = 1; x < self.mutableArrangedSubviews.count; x++) {
        previousItem = currentItem;
        currentItem = self.mutableArrangedSubviews[x];
        
        CGFloat currentPercent = percent * x;
        
        //keep items next to each other (left-aligned) when overlap is not needed
        [currentItem.centerXAnchor constraintLessThanOrEqualToAnchor:previousItem.centerXAnchor constant:self.itemSize.width].active = YES;
        
        //centerX as a percentage of the positionView width
        //note: this method is being used as opposed to the layout anchor API because the layout anchor API does not support setting the multiplier
        NSLayoutConstraint *constraint = [NSLayoutConstraint constraintWithItem:currentItem
                                                                      attribute:NSLayoutAttributeCenterX
                                                                      relatedBy:NSLayoutRelationEqual
                                                                         toItem:self.positionView
                                                                      attribute:NSLayoutAttributeTrailing
                                                                     multiplier:currentPercent
                                                                       constant:0.0];
        
        //this constraint needs a less-than-required priority so the left-aligned constraint can be enforced
        constraint.priority = UILayoutPriorityRequired - 1;
        constraint.active = YES;
    }
    
    //update the trailing anchor of the framing view to the last shelf item
    self.framingViewTrailingConstraint.active = NO;
    self.framingViewTrailingConstraint = [self.framingView.trailingAnchor constraintEqualToAnchor:currentItem.trailingAnchor];
    self.framingViewTrailingConstraint.active = YES;
}


- (void)addArrangedSubview:(UIView *)view inFront:(BOOL)inFront animated:(BOOL)animated {
    [self insertArrangedSubview:view atIndex:self.mutableArrangedSubviews.count inFront:inFront animated:animated];
}

- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated {
    [self addArrangedSubview:view inFront:NO animated:animated];
}

- (void)addArrangedSubview:(UIView *)view {
    [self addArrangedSubview:view animated:NO];
}

- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex inFront:(BOOL)inFront animated:(BOOL)animated {
    CGFloat height = MAX(view.bounds.size.height, view.bounds.size.width);
    
    //if the itemSize is CGSizeZero, then that means to use the size of the provided views
    if (!CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
        [NSLayoutConstraint activateConstraints:@[
            [view.widthAnchor constraintEqualToConstant:self.itemSize.width],
            [view.heightAnchor constraintEqualToConstant:self.itemSize.height]
        ]];
        height = MAX(self.itemSize.height, self.itemSize.width);
    }
    
    switch (self.itemShape) {
        case MyShelfItemShapeNone:
            break;
        case MyShelfItemShapeCircular:
            view.layer.cornerRadius = height / 2.0;
            break;
    }
    
    view.layer.borderColor = self.itemBorderColor.CGColor;
    view.layer.borderWidth = self.itemBorderWidth;
    view.translatesAutoresizingMaskIntoConstraints = NO;
    
    [self.mutableArrangedSubviews insertObject:view atIndex:stackIndex];
    
    if (inFront) {
        [self.positionView addSubview:view];
    } else {
        //insert the view as a subview of positionView at index zero so it will be underneath existing items
        [self.positionView insertSubview:view atIndex:0];
    }
    
    [NSLayoutConstraint activateConstraints:@[
        [view.centerYAnchor constraintEqualToAnchor:self.positionView.centerYAnchor]
    ]];
    
    [self invalidateIntrinsicContentSize];
    
    [self updateHorizontalPositions];
    [self updateVerticalPositions];
}

- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated {
    [self insertArrangedSubview:view atIndex:stackIndex inFront:NO animated:animated];
}

- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex {
    [self insertArrangedSubview:view atIndex:stackIndex animated:NO];
}

- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated {
    BOOL wasInFront = NO;
    if ([self.positionView.subviews lastObject] == view) {
        wasInFront = YES;
    }
    
    if (animated) {
        [self.mutableArrangedSubviews removeObject:view];
        [self invalidateIntrinsicContentSize];

        //clear the existing centerX constraints
//        for (NSLayoutConstraint *constraint in self.positionView.constraints) {
//            if (constraint.firstAttribute == NSLayoutAttributeCenterX || constraint.firstAttribute == NSLayoutAttributeCenterXWithinMargins) {
//                constraint.active = NO;
//            }
//        }

        [self layoutIfNeeded];
        
        __weak MyShelf *weakSelf = self;
        [UIView animateWithDuration:0.5
                              delay:0.0
                            options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
                         animations:^{
            view.alpha = 0.0;
            view.center = CGPointMake(-weakSelf.itemSize.width, view.center.y);
            [weakSelf updateHorizontalPositions];
            [weakSelf layoutIfNeeded];
        }
                         completion:^(BOOL finished){
            [view removeFromSuperview];
            
            
            //only reorder the views vertically if the one being removed was the top-most view
            if (wasInFront) {
                [weakSelf updateVerticalPositions];
            }

        }];
    } else {
        [view removeFromSuperview];
        [self.mutableArrangedSubviews removeObject:view];
        [self invalidateIntrinsicContentSize];
        [self updateHorizontalPositions];
        
        //only reorder the views verticall if the one being removed was the top-most view
        if (wasInFront) {
            [self updateVerticalPositions];
        }
    }
}

- (void)removeArrangedSubview:(UIView *)view {
    [self removeArrangedSubview:view animated:NO];
}


- (NSArray<__kindof UIView *> *)arrangedSubviews {
    return self.mutableArrangedSubviews;
}

#pragma mark - Managing the Vertical Order of Arranged Subviews
- (void)updateVerticalPositions {
    if (!self.positionView.subviews.count) {
        return;
    }
    
    //get the view that is on the top and find out what horizontal position it is in
    UIView *topView = [self.positionView.subviews lastObject];
    NSUInteger horizontalIndex = [self.mutableArrangedSubviews indexOfObject:topView];
    
    for (NSInteger x = horizontalIndex - 1; x >= 0; x--) {
        UIView *view = self.mutableArrangedSubviews[x];
        [self.positionView sendSubviewToBack:view];
    }
    
    for (NSInteger x = horizontalIndex + 1; x < self.mutableArrangedSubviews.count; x++) {
        UIView *view = self.mutableArrangedSubviews[x];
        [self.positionView sendSubviewToBack:view];
    }
}

- (void)bringArrangedSubviewToFront:(UIView *)view {
    [self.positionView bringSubviewToFront:view];
    [self updateVerticalPositions];
}



- (UIView *)framingView {
    if (!self->_framingView) {
        self->_framingView = [[UIView alloc] init];
        self->_framingView.translatesAutoresizingMaskIntoConstraints = NO;
        self->_framingView.backgroundColor = [UIColor systemYellowColor];
    }
    return self->_framingView;
}

- (UIView *)positionView {
    if (!self->_positionView) {
        self->_positionView = [[UIView alloc] init];
        self->_positionView.translatesAutoresizingMaskIntoConstraints = NO;
        self->_positionView.backgroundColor = nil;
    }
    return self->_positionView;
}

- (NSLayoutConstraint *)framingViewTrailingConstraint {
    if (!self->_framingViewTrailingConstraint) {
        self->_framingViewTrailingConstraint = [self.framingView.trailingAnchor constraintEqualToAnchor:self.positionView.leadingAnchor];
        self->_framingViewTrailingConstraint.priority = UILayoutPriorityRequired;
    }
    return self->_framingViewTrailingConstraint;
}

@end

Solution

  • I'm not an Apple engineer, so I don't know the ins-and-outs of this, but I've seen it often enough.

    As a general rule ... when animating constraints we want to allow auto-layout to manage the view hierarchy from a "top down" standpoint.

    If you change constraints and tell a subview to layoutIfNeeded, it appears that the superview either isn't made aware of what's going on, or the timing causes issues.

    If you change your animation block to [weakSelf.superview layoutIfNeeded]; that should fix the "jumping" problem:

        __weak MyShelf *weakSelf = self;
        [UIView animateWithDuration:0.5
                              delay:0.0
                            options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
                         animations:^{
            view.alpha = 0.0;
            view.center = CGPointMake(-weakSelf.itemSize.width, view.center.y);
            [weakSelf updateHorizontalPositions];
            
            // tell the superview to initiate auto-layout updates
            //[weakSelf layoutIfNeeded];
            [weakSelf.superview layoutIfNeeded];
            
        } completion:^(BOOL finished){
            [view removeFromSuperview];
            //only reorder the views vertically if the one being removed was the top-most view
            if (wasInFront) {
                [weakSelf updateVerticalPositions];
            }
        }];