Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Add subview later to animate alongside superview

I have a container view - let's call it socket view - that has a single subview, it's content view - let's call it plug view. This plug view can be nil, i.e. the socket view is currently empty. If it does contain a plug view, it is takes up the entire socket's space, i.e. its frame is the socket's bounds. From an outward perspective you should not even be able to tell that there are in fact two views, since the plug view is always exactly where the socket is.

I am struggling to get animations to work properly: if the plug view exists and is laid out prior to the animation, everything works as expected. However if I set the socket's plug view only when the animation is already running, I get an undesired effect:

The plug view is laid out to where it would be at the end of the animation and does not animate alongside its socket. I would like it to look like it has been there all the time but only became visible just now, i.e. the plug view (and its subviews) should animate alongside the socket, even if I add it while the animation is in progress.

How can I achieve this behavior?

My ideas: obviously the plug view has to be laid out twice: once for its final position, and once more for either where the socket view began animating, or where it has been added. I could compute this frame, apply it without animation and animate to the final frame within a new animation block. In order for the animation timing to be consistent, I would need to have the same curve and duration, but start the animation in the past or somehow forward it. Is this possible? Are there other approaches to having the plug view be full width and height at all times?


As a follow-up to Rob's answer, here are some more details of what exactly I am looking for:

  • The socket view is animating because it's owner's bound size changed. You can think of it as a full-width cell in a table view.

  • The plug view may contain subviews of its own along the likes of image views, labels and so on. These should as well join into the animation of the socket view, just as if they have always been there ever since the animation started.

  • While theoretically it is possible for a new animation to start while one is running already, I don't really mind the behavior in this edge case.

  • It is not necessary for the user to be able to interact with the plug view while the animation is running; this is most likely to happen during an interface orientation change anyway.

  • The plug view may decide to change its contents due to an asynchronous model update while animating, but again this is an edge case and I don't mind if the animation doesn't look perfect in this case. Its size, however, does not change - it is always the same as the plug view's size.

like image 423
Christian Schnorr Avatar asked Oct 28 '15 19:10

Christian Schnorr


2 Answers

You can find my test project here.

You say your plug view should fully, exactly cover the socket view. We need to worry about two things: the plug's layer position (layer.position) and layer size (layer.bounds.size).

By default, position controls the center of the layer (and view) because the default anchorPoint is (0.5, 0.5). If we set anchorPoint to (0, 0), then position controls the upper-left corner of the layer, and we always want that to be at (0, 0) in the socket's coordinate system. So by setting both anchorPoint and position to CGPointZero, we can avoid worrying about animating layer.position.

That just leaves us to animate layer.bounds.size.

When you animate a view using UIKit animations, it creates instances of CABasicAnimation under the hood. CABasicAnimation is a subclass of CAAnimation that adds (among other things) fromValue and toValue properties, specifying start and end values of the animation.

CAAnimation conforms to the CAMediaTiming protocol, which means it has a beginTime property. When you create a CAAnimation, it has a default beginTime of zero. Core Animation changes that to the current time (see CACurrentMediaTime) when it commits the current transaction.

But if the animation already has a non-zero beginTime, Core Animation uses it as-is. If that beginTime is in the past, then the animation is already partially (or even fully) completed when it first appears on-screen, and is drawn with the appropriate amount of progress already made. We can essentially “backdate” an animation.

So if we dig up the CABasicAnimation controlling the socket's bounds.size, and add it to the plug, the plug should animate in exactly the way we want. When we attach the animation to the plug, its beginTime is the time it started animating the socket. So even though we're attaching it to the plug later, it will be perfectly synchronized with the socket. And since we want the plug and the socket to have the same size, the fromValue and toValue are also already correct.

In my test app, I made the socket pink and the plug blue. Each is a UIImageView displaying an image with lines at the edges, so we can be sure that the views have the correct sizes at all times. Here's what it looks like in action:

simple demo

There is a further twist. If you animate a view that's already animating, UIKit doesn't halt the earlier animation. It adds the second animation, and both animations run concurrently.

This works because UIKit uses additive animations. When you animate a view's width from 320 to 160, UIKit sets the width to 160 immediately, and adds an additive animation that goes from 160 to 0. At the start of the animation, the apparent width is 160+160=320, and at the end, the apparent width is 160+0=160.

When UIKit adds a second animation while the first is running, the values of both animations are added to the apparent value used to draw view on the screen. Here's the effect:

multiple animations

What this means for us is we have to look for all of the socket animations with a keyPath of position.size, and copy all of them to the plug. Here's the code in my test app:

- (IBAction)plugWasTapped:(id)sender {
    if (self.plugView.superview) {
        [self.plugView removeFromSuperview];
        return;
    }

    self.plugView.frame = self.socketView.bounds;
    [self.socketView addSubview:self.plugView];

    for (NSString *key in self.socketView.layer.animationKeys) {
        CAAnimation *rawAnimation = [self.socketView.layer animationForKey:key];
        if (![rawAnimation isKindOfClass:[CABasicAnimation class]]) {
            continue;
        }

        CABasicAnimation *animation = (CABasicAnimation *)rawAnimation;
        if ([animation.keyPath isEqualToString:@"bounds.size"]) {
            [self.plugView.layer addAnimation:animation forKey:key];
        }
    }
}

Here's the result with multiple simultaneous animations:

demo with multiple animations

UPDATE

Getting the full view hierarchy of the plug view to animate as if it had been there in the first place is frankly too much work.

Here's one alternative:

  1. Lay out the plug view at the original size of the socket and create an image of it (the "begin image").
  2. Lay out the plug view at the final size of the socket and create an image of it (the "end image").
  3. Put a placeholder image view into the socket.
  4. Copy the size animation from the socket to the placeholder.
  5. Use the size animation to create a contents animation on the placeholder that crossfades its contents from the begin image to the end image.
  6. When the animation ends, replace the placeholder with the plug view.

Here's what it looks like:

crossfade demo

In addition to the imperfection of the crossfade, this version doesn't handle multiple animations running simultaneously. You can find this under the crossfade tag in my repository (linked at the top).

Another approach is to just render the plug view at its final size and put that in the placeholder without a crossfade. It looks like this:

stretch end image demo

I think it looks better this way, and this version handles stacked animations. You can find this under the stretch-end-image tag in my repository.

Finally, there's an entirely different approach that I didn't implement. This will only apply to resizing animations that you create yourself—it won't work for the rotation animation created by the system on an orientation change. You can simply animate the bounds of your socket view yourself, with a timer, instead of using UIKit animation. WWDC 2012 Session 228: Best Practices for Mastering Auto Layout discusses this toward the end. He suggests using an NSTimer but I think you'd be better off using a CADisplayLink. If you take this approach, the plug view's subviews will all animate perfectly. It's a fair bit more work than using UIKit animations, but should be straightforward to implement.

like image 90
rob mayoff Avatar answered Nov 13 '22 03:11

rob mayoff


I modified Rob mayoff code this way. Does this help you?

The important changes are in actually using CADisplayLink to update the frame of the plugView and I added an subview with constraints to the plugView. Also changed how the plugView is added and its frame updated.

Just check if this works on you. You should be able to replace this code on the project with no issues.

#import "ViewController.h"

@interface UIView (recursiveDescription)
- (NSString *)recursiveDescription;
@end

@interface ViewController ()

@property (strong, nonatomic) IBOutlet NSLayoutConstraint *socketWidthConstraint;
@property (strong, nonatomic) IBOutlet NSLayoutConstraint *socketHeightConstraint;
@property (strong, nonatomic) IBOutlet UIView *socketView;
@property (strong, nonatomic) IBOutlet UIImageView *plugView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    UIView *extraView = [[UIView alloc] init];
    extraView.translatesAutoresizingMaskIntoConstraints = NO;
    extraView.backgroundColor = [UIColor blackColor];
    [self.plugView addSubview:extraView];
    NSLayoutConstraint *c1 = [NSLayoutConstraint constraintWithItem:extraView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.plugView attribute:NSLayoutAttributeTop multiplier:1.0 constant:0];
    NSLayoutConstraint *c2 = [NSLayoutConstraint constraintWithItem:extraView attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:self.plugView attribute:NSLayoutAttributeLeading multiplier:1.0 constant:0];
    NSLayoutConstraint *c3 = [NSLayoutConstraint constraintWithItem:extraView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:self.plugView attribute:NSLayoutAttributeWidth multiplier:0.5 constant:0];
    NSLayoutConstraint *c4 = [NSLayoutConstraint constraintWithItem:extraView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:self.plugView attribute:NSLayoutAttributeHeight multiplier:0.5 constant:0];
    [self.plugView addConstraints:@[c1, c2, c3, c4]];

}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];

    CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkDidFire:)];
    [link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)displayLinkDidFire:(CADisplayLink *)link {
    CGSize size = [self.socketView.layer.presentationLayer frame].size;
    self.plugView.frame = CGRectMake(0, 0, size.width, size.height);
    [self.plugView layoutIfNeeded];
}

- (IBAction)plugWasTapped:(id)sender {
    if (self.plugView.superview) {
        [self.plugView removeFromSuperview];
        return;
    }

    CGSize size = [self.socketView.layer.presentationLayer frame].size;
    self.plugView.frame = CGRectMake(0, 0, size.width, size.height);
    [self.socketView addSubview:self.plugView];
}
- (IBAction)bigButtonWasTapped:(id)sender {
    [UIView animateWithDuration:10 animations:^{
        self.socketWidthConstraint.constant = 320;
        self.socketHeightConstraint.constant = 320;
        [self.view layoutIfNeeded];
    }];
}

- (IBAction)smallButtonWasTapped:(id)sender {
    [UIView animateWithDuration:5 animations:^{
        self.socketWidthConstraint.constant = 160;
        self.socketHeightConstraint.constant = 160;
        [self.view layoutIfNeeded];
    }];
}


@end
like image 21
Matías Marquez Avatar answered Nov 13 '22 02:11

Matías Marquez