Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to execute a lock that waits until an animation completes?

I am implementing a health bar that animates via user input.

These animations make it go up or down by a certain amount (say 50 units) and are the result of a button press. There are two buttons. Increase and Decrease.

I want to execute a lock on the health bar so that only one thread can change it at a time. The problem is I'm getting a deadlock.

I'm guessing because a separate thread runs a lock that is held by another thread. But that lock will give way when the animation completes. How do you implement a lock that ends when [UIView AnimateWithDuration] completes?

I wonder if NSConditionLock is the way to go, but I want to use NSLocks if possible to avoid unnecessary complexity. What do you recommend?

(Eventually I want to have the animations "queue up" while letting user input continue, but for now I just want to get the lock working, even if it blocks input first.)

(Hmm come to think of it there is only one [UIView AnimateWithDuration] running at a time for the same UIView. A second call will interrupt the first, causing the completion handler to run immediately for the first. Maybe the second lock runs before the first has a chance to unlock. What's the best way to handle locking in this case? Perhaps I should revisit Grand Central Dispatch but I wanted to see if there was a simpler way.)

In ViewController.h I declare:

NSLock *_lock;

In ViewController.m I have:

In loadView:

_lock = [[NSLock alloc] init];

The rest of ViewController.m (relevant parts):

-(void)tryTheLockWithStr:(NSString *)str
{
    LLog(@"\n");
    LLog(@" tryTheLock %@..", str);

    if ([_lock tryLock] == NO)
    {
         NSLog(@"LOCKED.");
    }
          else
    {
          NSLog(@"free.");
          [_lock unlock];
    }
}

// TOUCH DECREASE BUTTON
-(void)touchThreadButton1
{
    LLog(@" touchThreadButton1..");

    [self tryTheLockWithStr:@"beforeLock"];
    [_lock lock];
    [self tryTheLockWithStr:@"afterLock"];

    int changeAmtInt = ((-1) * FILLBAR_CHANGE_AMT); 
    [self updateFillBar1Value:changeAmtInt];

    [UIView animateWithDuration:1.0
                      delay:0.0
                    options:(UIViewAnimationOptionTransitionNone|UIViewAnimationOptionBeginFromCurrentState|UIViewAnimationOptionAllowUserInteraction)
                 animations:
     ^{

         LLog(@" BEGIN animationBlock - val: %d", self.fillBar1Value)
         self.fillBar1.frame = CGRectMake(FILLBAR_1_X_ORIGIN,FILLBAR_1_Y_ORIGIN, self.fillBar1Value,30);
     }
     completion:^(BOOL finished)
     {
         LLog(@" END animationBlock - val: %d - finished: %@", self.fillBar1Value, (finished ? @"YES" : @"NO"));

         [self tryTheLockWithStr:@"beforeUnlock"];
         [_lock unlock];
         [self tryTheLockWithStr:@"afterUnlock"];         
     }
     ];
}

-(void)updateFillBar1Value:(int)changeAmt
{
    self.prevFillBar1Value = self.fillBar1Value;

    self.fillBar1Value += changeAmt;

    if (self.fillBar1Value < FILLBAR_MIN_VALUE)
    {
        self.fillBar1Value = FILLBAR_MIN_VALUE;
    }
    else if (self.fillBar1Value > FILLBAR_MAX_VALUE)
    {
        self.fillBar1Value = FILLBAR_MAX_VALUE;
    }
}

Output:

To-Reproduce Instructions: Tap "Decrease" once

touchThreadButton1..

tryTheLock beforeLock.. free.

tryTheLock afterLock.. LOCKED. BEGIN animationBlock - val: 250 END animationBlock - val: 250 - finished: YES

tryTheLock beforeUnlock.. LOCKED.

tryTheLock afterUnlock.. free.

Conclusion: This works as expected.

--

Output:

To-Reproduce Instructions: Tap "Decrease" twice quickly (interrupting the initial animation)..

touchThreadButton1..

tryTheLock beforeLock.. free.

tryTheLock afterLock.. LOCKED. BEGIN animationBlock - val: 250 touchThreadButton1..

tryTheLock beforeLock.. LOCKED. * -[NSLock lock]: deadlock ( '(null)') * Break on _NSLockError() to debug.

Conclusion. Deadlock error. User input is frozen.

like image 950
Black Orchid Avatar asked Aug 24 '13 20:08

Black Orchid


1 Answers

At the bottom, in my original answer, I describe a way to achieve the requested functionality (if you initiate an animation while the prior animation is still in progress, queue up this subsequent animation to only start after the current ones are done).

While I'll keep that for historical purposes, I might want to suggest an entirely different approach. Specifically, if you tap on a button that should result in an animation, but a previous animation is still in progress, I would instead suggest that you remove the old animation and immediately start the new animation, but do so in manner such that the new animation picks up from wherever the current one left off.

  1. In iOS versions prior to iOS 8, the challenge is that if you start a new animation while another is in progress, the OS awkwardly immediately jumps to where the current animation would have ended and starts the new animation from there.

    The typical solution in iOS versions prior to 8 would be to:

    • grab the presentationLayer of the animated view (this is the current state of the CALayer of the UIView ... if you look at the UIView while the animation is in progress, you'll see the final value, and we need to grab the current state);

    • grab the current value of the animated property value from that presentationLayer;

    • remove the animations;

    • reset the animated property to the "current" value (so that it doesn't appear to jump to the end of the prior animation before starting the next animation);

    • initiate the animation to the "new" value;

    So, for example, if you are animating the changing of a frame which might be in progress of an animation, you might do something like:

    CALayer *presentationLayer = animatedView.layer.presentationLayer;
    CGRect currentFrame = presentationLayer.frame;
    [animatedView.layer removeAllAnimations];
    animatedView.frame = currentFrame;
    [UIView animateWithDuration:1.0 animations:^{
        animatedView.frame = newFrame;
    }];
    

    This completely eliminates all of the awkwardness associated with queuing up the "next" animation to run after the "current" animation (and other queued animations) completes. You end up with a far more responsive UI, too (e.g. you don't have to wait for prior animations to finish before the user's desired animation commences).

  2. In iOS 8, this process is infinitely easier, where it will, if you initiate a new animation, it will often start the animation from not only the current value of the animated property, but will also identify the speed at which that this currently animated property is changing, resulting in a seamless transition between the old animation and the new animation.

    For more information about this new iOS 8 feature, I'd suggest you refer to WWDC 2014 video Building Interruptible and Responsive Interactions.

For the sake of completeness, I'll keep my original answer below, as it tries to precisely tackle the functionality outlined in the question (just uses a different mechanism to ensure the main queue isn't blocked). But I'd really suggest considering stopping the current animation and starting the new one in such a manner that it commences from wherever any in-progress animation might have left off.


Original answer:

I wouldn't recommend wrapping an animation in a NSLock (or semaphore, or any other similar mechanism) because that can result in blocking the main thread. You never want to block the main thread. I think your intuition about using a serial queue for resizing operations is promising. And you probably want a "resizing" operation that:

  • initiates the UIView animation on the main queue (all UI updates must take place on the main queue); and

  • in the animation's completion block, finish the operation (we don't finish the operation until then, to ensure that other queued operations don't initiate until this one finished).

I might suggest a resizing operation:

SizeOperation.h:

@interface SizeOperation : NSOperation

@property (nonatomic) CGFloat sizeChange;
@property (nonatomic, weak) UIView *view;

- (id)initWithSizeChange:(NSInteger)change view:(UIView *)view;

@end

SizingOperation.m:

#import "SizeOperation.h"

@interface SizeOperation ()

@property (nonatomic, readwrite, getter = isFinished)  BOOL finished;
@property (nonatomic, readwrite, getter = isExecuting) BOOL executing;

@end

@implementation SizeOperation

@synthesize finished = _finished;
@synthesize executing = _executing;

- (id)initWithSizeChange:(NSInteger)change view:(UIView *)view
{
    self = [super init];
    if (self) {
        _sizeChange = change;
        _view = view;
    }
    return self;
}

- (void)start
{
    if ([self isCancelled] || self.view == nil) {
        self.finished = YES;
        return;
    }

    self.executing = YES;

    // note, UI updates *must* take place on the main queue, but in the completion
    // block, we'll terminate this particular operation

    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        [UIView animateWithDuration:2.0 delay:0.0 options:kNilOptions animations:^{
            CGRect frame = self.view.frame;
            frame.size.width += self.sizeChange;
            self.view.frame = frame;
        } completion:^(BOOL finished) {
            self.finished = YES;
            self.executing = NO;
        }];
    }];
}

#pragma mark - NSOperation methods

- (void)setExecuting:(BOOL)executing
{
    [self willChangeValueForKey:@"isExecuting"];
    _executing = executing;
    [self didChangeValueForKey:@"isExecuting"];
}

- (void)setFinished:(BOOL)finished
{
    [self willChangeValueForKey:@"isFinished"];
    _finished = finished;
    [self didChangeValueForKey:@"isFinished"];
}

@end

Then define a queue for these operations:

@property (nonatomic, strong) NSOperationQueue *sizeQueue;

Make sure to instantiate this queue (as a serial queue):

self.sizeQueue = [[NSOperationQueue alloc] init];
self.sizeQueue.maxConcurrentOperationCount = 1;

And then, anything that makes the view in question grow, would do:

[self.sizeQueue addOperation:[[SizeOperation alloc] initWithSizeChange:+50.0 view:self.barView]];

And anything that makes the view in question shrink, would do:

[self.sizeQueue addOperation:[[SizeOperation alloc] initWithSizeChange:-50.0 view:self.barView]];

Hopefully this illustrates the idea. There are all sorts of possible refinements:

  • I made the animation really slow, so I could easily queue up a whole bunch, but you'd probably be using a much shorter value;

  • If using auto layout, you'd be adjusting the width constraint's constant and in the animation block you'd perform a layoutIfNeeded) rather than adjusting the frame directly; and

  • You probably want to add checks to not perform the frame change if the width has hit some maximum/minimum values.

But the key is that using locks to control animation of UI changes is inadvisable. You don't want anything that could block the main queue for anything but a few milliseconds. Animation blocks are too long to contemplate blocking the main queue. So use a serial operation queue (and if you have multiple threads that need to initiate changes, they'd all just add an operation to the same shared operation queue, thereby automatically coordinating changes initiated from all sorts of different threads).

like image 119
Rob Avatar answered Nov 11 '22 12:11

Rob