94

I'm getting some strange behaviour with presentViewController:animated:completion. What I'm making is essentially a guessing game.

I have a UIViewController (frequencyViewController) containing a UITableView (frequencyTableView). When the user taps on the row in questionTableView containing the correct answer, a view (correctViewController) should be instantiate and its view should slide up from the bottom of the screen, as a modal view. This tells the user they have a correct answer and resets the frequencyViewController behind it ready for the next question. correctViewController is dismissed on a button press to reveal the next question.

This all works correctly every time, and the correctViewController's view appear instantly as long as presentViewController:animated:completion has animated:NO.

If I set animated:YES, correctViewController is initialized and makes calls to viewDidLoad. However viewWillAppear, viewDidAppear, and the completion block from presentViewController:animated:completion are not called. The app just sits there still showing frequencyViewController until I make a second tap. Now, viewWillAppear, viewDidAppear and the completion block are called.

I investigated a bit more, and it's not just another tap that will cause it to continue. It seems if I tilt or shake my iPhone this can also cause it to trigger the viewWillLoad etc. It's like it's waiting to any other bit of user input before it will progress. This happens on a real iPhone and in the simulator, which I proved by sending the shake command to the simulator.

I'm really at a loss as to what to do about this... I'd really appreciate any help anyone can provide.

Thanks

Here's my code. It's pretty simple...

This is code in questionViewController that acts as the delegate to the questionTableView

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{

    if (indexPath.row != [self.frequencyModel currentFrequencyIndex])
    {
        // If guess was wrong, then mark the selection as incorrect
        NSLog(@"Incorrect Guess: %@", [self.frequencyModel frequencyLabelAtIndex:(int)indexPath.row]);
        UITableViewCell *cell = [self.frequencyTableView cellForRowAtIndexPath:indexPath];
        [cell setBackgroundColor:[UIColor colorWithRed:240/255.0f green:110/255.0f blue:103/255.0f alpha:1.0f]];            
    }
    else
    {
        // If guess was correct, show correct view
        NSLog(@"Correct Guess: %@", [self.frequencyModel frequencyLabelAtIndex:(int)indexPath.row]);
        self.correctViewController = [[HFBCorrectViewController alloc] init];
        self.correctViewController.delegate = self;
        [self presentViewController:self.correctViewController animated:YES completion:^(void){
            NSLog(@"Completed Presenting correctViewController");
            [self setUpViewForNextQuestion];
        }];
    }
}

This is the whole of the correctViewController

@implementation HFBCorrectViewController

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    if (self)
    {
        // Custom initialization
        NSLog(@"[HFBCorrectViewController initWithNibName:bundle:]");
    }
    return self;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    // Do any additional setup after loading the view from its nib.
    NSLog(@"[HFBCorrectViewController viewDidLoad]");
}

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    NSLog(@"[HFBCorrectViewController viewDidAppear]");
}

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

- (IBAction)close:(id)sender
{
    NSLog(@"[HFBCorrectViewController close:sender:]");
    [self.delegate didDismissCorrectViewController];
}


@end

Edit:

I found this question earlier: UITableView and presentViewController takes 2 clicks to display

And if I change my didSelectRow code to this, it works very time with animation... But it's messy and doesn't make sense as to why it doesn't work in the first place. So I don't count that as an answer...

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{

    if (indexPath.row != [self.frequencyModel currentFrequencyIndex])
    {
        // If guess was wrong, then mark the selection as incorrect
        NSLog(@"Incorrect Guess: %@", [self.frequencyModel frequencyLabelAtIndex:(int)indexPath.row]);
        UITableViewCell *cell = [self.frequencyTableView cellForRowAtIndexPath:indexPath];
        [cell setBackgroundColor:[UIColor colorWithRed:240/255.0f green:110/255.0f blue:103/255.0f alpha:1.0f]];
        // [cell setAccessoryType:(UITableViewCellAccessoryType)]

    }
    else
    {
        // If guess was correct, show correct view
        NSLog(@"Correct Guess: %@", [self.frequencyModel frequencyLabelAtIndex:(int)indexPath.row]);

        ////////////////////////////
        // BELOW HERE ARE THE CHANGES
        [self performSelector:@selector(showCorrectViewController:) withObject:nil afterDelay:0];
    }
}

-(void)showCorrectViewController:(id)sender
{
    self.correctViewController = [[HFBCorrectViewController alloc] init];
    self.correctViewController.delegate = self;
    self.correctViewController.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
    [self presentViewController:self.correctViewController animated:YES completion:^(void){
        NSLog(@"Completed Presenting correctViewController");
        [self setUpViewForNextQuestion];
    }];
}
Community
  • 1
  • 1
HalfNormalled
  • 943
  • 1
  • 7
  • 6
  • 1
    I have the same problem. I checked with NSLogs that I don't call any methods that take a long time to execute. It appears that it's just the animation that `presentViewController:` is supposed to trigger starts with a big delay. This seems to be a bug in iOS 7 and is also discussed in the Apple Dev Forums. – Theo Jan 31 '14 at 09:36
  • I have the same problem. Does seem like an iOS7 bug - doesn't happen on iOS6. Also, in my case it happens only the first time after I open the presenting view controller. @Theo, can you supply a link to the Apple Dev Forums? – AXE Mar 04 '14 at 13:26

8 Answers8

162

I've encountered the same issue today. I dug into the topic and it seems that it's related to the main runloop being asleep.

Actually it's a very subtle bug, because if you have the slightest feedback animation, timers, etc. in your code this issue won't surface because the runloop will be kept alive by these sources. I've found the issue by using a UITableViewCell which had its selectionStyle set to UITableViewCellSelectionStyleNone, so that no selection animation triggered the runloop after the row selection handler ran.

To fix it (until Apple does something) you can trigger the main runloop by several means:

The least intrusive solution is to call CFRunLoopWakeUp:

[self presentViewController:vc animated:YES completion:nil];
CFRunLoopWakeUp(CFRunLoopGetCurrent());

Or you can enqueue an empty block to the main queue:

[self presentViewController:vc animated:YES completion:nil];
dispatch_async(dispatch_get_main_queue(), ^{});

It's funny, but if you shake the device, it'll also trigger the main loop (it has to process the motion events). Same thing with taps, but that's included in the original question :) Also, if the system updates the status bar (e.g. the clock updates, the WiFi signal strength changes etc.) that'll also wake up the main loop and present the view controller.

For anyone interested I wrote a minimal demonstration project of the issue to verify the runloop hypothesis: https://github.com/tzahola/present-bug

I've also reported the bug to Apple.

Tamás Zahola
  • 9,271
  • 4
  • 34
  • 46
69

Check this out: https://devforums.apple.com/thread/201431 If you don't want to read it all - the solution for some people (including me) was to make the presentViewController call explicitly on the main thread:

Swift 4.2:

DispatchQueue.main.async { 
    self.present(myVC, animated: true, completion: nil)
}

Objective-C:

dispatch_async(dispatch_get_main_queue(), ^{
    [self presentViewController:myVC animated:YES completion:nil];
});

Probably iOS7 is messing up the threads in didSelectRowAtIndexPath.

Kqtr
  • 5,824
  • 3
  • 25
  • 32
AXE
  • 8,335
  • 6
  • 25
  • 32
  • wow - this worked for me. I was seeing the delays too. I cannot understand why this should be necessary. Thanks for posting this. – drudru Oct 27 '14 at 18:39
  • 4
    Filed a radar with Apple about this problem: rdar://19563577 http://openradar.appspot.com/19563577 – mluisbrown Jan 22 '15 at 15:27
  • 1
    Im on iOS 8 and this does not fix it for me - it is always reporting being on the main thread, but I still see the issue described. – Robert Mar 18 '15 at 11:48
8

I bypassed it in Swift 3.0 by using the following code:

DispatchQueue.main.async { 
    self.present(UIViewController(), animated: true, completion: nil)
}
Mark
  • 16,906
  • 20
  • 84
  • 117
2

Calling [viewController view] on the view controller being presented did the trick for me.

Matt Fenwick
  • 48,199
  • 22
  • 128
  • 192
JaganY
  • 53
  • 4
0

I'd be curious to see what [self setUpViewForNextQuestion]; does.

You could try calling [self.correctViewController.view setNeedsDisplay]; at the end of your completion block in presentViewController.

Nick
  • 2,361
  • 16
  • 27
  • Right now, setUpViewForNextQuestion just calls reloadData on the tableview (to change all the background colours back to white if any had been set to red by an incorrect guess). But would any changes there make any difference? The issue is that correctViewController doesn't appear until the use taps again, so this completion block is not being called until after that second tap. – HalfNormalled Jan 12 '14 at 15:31
  • I did try your suggestion, but it didn't make any difference. As I say, the view isn't appearing, so the completion block isn't called until after the second tap, or the shake gesture. – HalfNormalled Jan 12 '14 at 15:32
  • hmm. for starters, i would use breakpoints to confirm that your presentation code is not called until the 2nd tap. (versus being called on the first tap, but not being drawn onscreen until some later time). if the latter is true, try forcing your presentation to occur on the main thread. When you use "performSelectorWithDelay:0" that forces the app to wait until the next iteration of the run loop before executing. It could be threading-related. – Nick Jan 12 '14 at 16:38
  • Thanks, Nick. How would I check using breakpoints? At the moment I'musing NSLog to check when each method is being called. This is what I have now: Tap 1 `Correct Guess: 1 kHz 18:04:57.173 Frequency[641:60b] [HFBCorrectViewController initWithNibName:bundle:] 18:04:57.177 Frequency[641:60b] [HFBCorrectViewController viewDidLoad]` Now nothing happens until:Tap 2 `18:05:00.515 Frequency[641:60b] [HFBCorrectViewController viewDidAppear] 18:05:00.516 Frequency[641:60b] Completed Presenting correctViewController 18:05:00.517 Frequency[641:60b] [HFBFrequencyViewController setUpViewForNextQuestion]` – HalfNormalled Jan 12 '14 at 18:21
  • Sorry, should have been more explicit. I understand how to use breakpoints. I get the same story as with NSLog. I put breakpoints on viewDidLoad and viewDidAppear in the correctViewController. It breaks on viewDidLoad, then when I continue, it sits and waits for another tap, then breaks on viewDidAppear. Sometimes it seems to not need a tap and it's time based, by the time I've stepped through things looking at what else is getting called, it's got round to calling viewDidLoad. – HalfNormalled Jan 12 '14 at 21:04
0

I've wrote extension (category) with method swizzling for UIViewController that solves the issue. Thanks to AXE and NSHipster for implementation hints (swift/objective-c).

Swift

extension UIViewController {

 override public class func initialize() {
    struct DispatchToken {
      static var token: dispatch_once_t = 0
    }
    if self != UIViewController.self {
      return
    }
    dispatch_once(&DispatchToken.token) {
      let originalSelector = Selector("presentViewController:animated:completion:")
      let swizzledSelector = Selector("wrappedPresentViewController:animated:completion:")

      let originalMethod = class_getInstanceMethod(self, originalSelector)
      let swizzledMethod = class_getInstanceMethod(self, swizzledSelector)

      let didAddMethod = class_addMethod(self, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod))

      if didAddMethod {
        class_replaceMethod(self, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod))
      }
      else {
        method_exchangeImplementations(originalMethod, swizzledMethod)
      }
    }
  }

  func wrappedPresentViewController(viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) {
    dispatch_async(dispatch_get_main_queue()) {
      self.wrappedPresentViewController(viewControllerToPresent, animated: flag, completion: completion)
    }
  }
}  

Objective-C

#import <objc/runtime.h>

@implementation UIViewController (Swizzling)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector = @selector(presentViewController:animated:completion:);
        SEL swizzledSelector = @selector(wrappedPresentViewController:animated:completion:);

        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

        BOOL didAddMethod =
            class_addMethod(class,
                originalSelector,
                method_getImplementation(swizzledMethod),
                method_getTypeEncoding(swizzledMethod));

        if (didAddMethod) {
            class_replaceMethod(class,
                swizzledSelector,
                method_getImplementation(originalMethod),
                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)wrappedPresentViewController:(UIViewController *)viewControllerToPresent 
                             animated:(BOOL)flag 
                           completion:(void (^ __nullable)(void))completion {
    dispatch_async(dispatch_get_main_queue(),^{
        [self wrappedPresentViewController:viewControllerToPresent
                                  animated:flag 
                                completion:completion];
    });

}

@end
Gladkov_Art
  • 23
  • 1
  • 5
0

Check if your cell in the storyboard has Selection = none

If so, change it to blue or grey and it should work

Mkey
  • 291
  • 3
  • 3
0

XCode Vesion : 9.4.1, Swift 4.1

In my case this happen, when I tap cell and move for the another view. I debug into the deeper and it seems that happen inside viewDidAppear because of contains following code

if let indexPath = tableView.indexPathForSelectedRow {
   tableView.deselectRow(at: indexPath, animated: true)
}

then I added above code segment inside prepare(for segue: UIStoryboardSegue, sender: Any?) and work perfect.

Within my experience, my solution is, If we'll hope to do any new changes(eg. table reload, deselect selected cell etc.) for the tableview when again come back from second view, then use delegate instead of viewDidAppear and using above tableView.deselectRow code segment before moving second view controller

Sachintha Udara
  • 635
  • 1
  • 7
  • 22