61

This is a follow-up to How to get notified when a tableViewController finishes animating the push onto a nav stack.

In a tableView I want to deselect a row with animation, but only after the tableView has finished animating the scroll to the selected row. How can I be notified when that happens, or what method gets called the moment that finishes.

This is the order of things:

  1. Push view controller
  2. In viewWillAppear I select a certain row.
  3. In viewDidAppear I scrollToRowAtIndexPath (to the selected row).
  4. Then when that finishes scrolling I want to deselectRowAtIndexPath: animated:YES

This way, the user will know why they were scrolled there, but then I can fade away the selection.
Step 4 is the part I haven't figured out yet. If I call it in viewDidAppear then by the time the tableView scrolls there, the row has been deselected already which is no good.

Community
  • 1
  • 1
Andrew
  • 8,363
  • 8
  • 43
  • 71

6 Answers6

85

You can use the table view delegate's scrollViewDidEndScrollingAnimation: method. This is because a UITableView is a subclass of UIScrollView and UITableViewDelegate conforms to UIScrollViewDelegate. In other words, a table view is a scroll view, and a table view delegate is also a scroll view delegate.

So, create a scrollViewDidEndScrollingAnimation: method in your table view delegate and deselect the cell in that method. See the reference documentation for UIScrollViewDelegate for information on the scrollViewDidEndScrollingAnimation: method.

James Huddleston
  • 8,410
  • 5
  • 34
  • 39
  • 69
    It would be nice if iOS provides a version with completion block (like `UIView animateWithDuration:animations:completion`), so the notification can be context-specific... – pixelfreak Dec 09 '11 at 22:09
  • 29
    What if the table view decides that the scroll animation is not required (because the row is already on screen)? The row will never be deselected if the scroll animation method doesn't fire. – Ben Packard Jan 08 '13 at 18:45
  • 7
    @BenPackard, you could check that in advance... `[myTableView.indexPathsForVisibleRows containsObject:myIndexPath]` – Pim Nov 06 '13 at 07:41
  • 1
    The method is not called not called on iOS 7.1 – DarthMike Mar 26 '14 at 11:51
60

try this

[UIView animateWithDuration:0.3 animations:^{
    [yourTableView scrollToRowAtIndexPath:indexPath 
                         atScrollPosition:UITableViewScrollPositionTop 
                                 animated:NO];
} completion:^(BOOL finished){
    //do something
}];

Don't forget to set animated to NO, the animation of scrollToRow will be overridden by UIView animateWithDuration.

Hope this help !

Pung Worathiti Manosroi
  • 1,432
  • 1
  • 20
  • 23
  • 2
    I was able to do what I wanted based on this suggestion, so I guess I own you one :) – alasker Dec 27 '15 at 22:09
  • 9
    CAUTION WITH USING THIS SOLUTION: I was using this method for a while, until I saw strange behavior in cells that were scrolled to. Images that were hidden would be displayed... unexplainable. View debugging would show they were not visible. But they were when run on a device. I isolated the issue to the scrollToRowAtIndexPath with animated set to NO inside of the animation block. Move it outside the animation block and no issue. But then no animation... So this technique can cause problems. I updated to use James Huddleston's scrollViewDidEndScrollingAnimation solution to fix the problem. – Jim Range Mar 09 '18 at 19:34
  • In iOS13, i am seeing cpu usage above 100% after doing this. and animations gone all slow. – RicardoDuarte Jan 27 '20 at 23:32
15

To address Ben Packard's comment on the accepted answer, you can do this. Test if the tableView can scroll to the new position. If not, execute your method immediately. If it can scroll, wait until the scrolling is finished to execute your method.

- (void)someMethod
{
    CGFloat originalOffset = self.tableView.contentOffset.y;
    [self.tableView scrollToRowAtIndexPath:path atScrollPosition:UITableViewScrollPositionMiddle animated:NO];
    CGFloat offset = self.tableView.contentOffset.y;

    if (originalOffset == offset)
    {
        // scroll animation not required because it's already scrolled exactly there
        [self doThingAfterAnimation];
    }
    else
    {
        // We know it will scroll to a new position
        // Return to originalOffset. animated:NO is important
        [self.tableView setContentOffset:CGPointMake(0, originalOffset) animated:NO];
        // Do the scroll with animation so `scrollViewDidEndScrollingAnimation:` will execute
        [self.tableView scrollToRowAtIndexPath:path atScrollPosition:UITableViewScrollPositionMiddle animated:YES];
    }
}

- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
{
    [self doThingAfterAnimation];
}
erik
  • 6,406
  • 3
  • 36
  • 36
  • 1
    How about using `[self.tableView.indexPathsForVisibleRows containsObject:path]`? – Pim Nov 06 '13 at 07:43
  • 2
    It's true that if indexPath *isn't* visible, it will definitely need to be scrolled to be positioned correctly, so you could skip the `contentOffset` fiddling in that case. However, if the indexPath **is** visible, the tableView may or may not be able to scroll it into position, so you should check the contentOffsets (the same as shown above). – EthanB Nov 12 '13 at 17:00
  • It's possible your current and new content offsets could be really close (such as 1960.3333129882812 vs 1960.3333333333333) in which case the request to scroll will not actually scroll and `scrollViewDidEndScrolling` will not get called. To fix, I round them down before comparing. – Jordan H Dec 01 '22 at 21:47
12

You can include the scrollToRowAtIndexPath: inside a [UIView animateWithDuration:...] block which will trigger the completion block after all included animations conclude. So, something like this:

[UIView
    animateWithDuration:0.3f
    delay:0.0f
    options:UIViewAnimationOptionAllowUserInteraction
    animations:^
    {
        // Scroll to row with animation
        [self.tableView scrollToRowAtIndexPath:indexPath
                            atScrollPosition:UITableViewScrollPositionTop
                                    animated:YES];
    }
    completion:^(BOOL finished)
    {
        // Deselect row
        [self.tableView deselectRowAtIndexPath:indexPath animated:YES];
    }];
user1898712
  • 368
  • 6
  • 18
Mr. T
  • 12,795
  • 5
  • 39
  • 47
  • Clever :) Although that overrides the default animation duration for the scrollToRowAtIndexPath? – BastiBen Mar 19 '14 at 13:28
  • correct, it does override the default duration, but depending on the value you set, it could make your app act distinctly/uniquely (which could be a good thing) or you could find a value so close to the default that it becomes nearly indiscernible to your users. – Mr. T Mar 19 '14 at 20:59
  • Good answer - but `animated` parameter of `scrollToRowAtIndexPath` should be set to NO - it is already inside the animation block. – Rok Jarc Apr 25 '14 at 09:51
  • 7
    I find that this solution unfortunately causes some artifacts when scrolling long lists: all but the cells to be visible when scrolling stops aren't drawn and appear white, causing a flicker. – SwiftArchitect May 07 '14 at 02:35
  • 1
    The weak reference to the self (`me`) is also not reasonable. – Balazs Nemeth May 14 '15 at 22:00
  • 1
    As of iOS 9, scrolling long lists does not seem to "flicker" if you use the animation `duration` of `0.00` and `animated` of `NO`. – Gary Aug 24 '16 at 07:45
4

Swift 5

The scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) delegate method is indeed the best way to execute a completion on a scroll-to-row animation but there are two things worth noting:

First, the documentation incorrectly says that this method is only called in response to setContentOffset and scrollRectToVisible; it's also called in response to scrollToRow (https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/1619379-scrollviewdidendscrollinganimati).

Second, despite the fact that the method is called on the main thread, if you're running a subsequent animation here (one after the scroll has finished), it will still hitch (this may or may not be a bug in UIKit). Therefore, simply dispatch any follow-up animations back onto the main queue which just ensures that the animations will begin after the end of the current main task (which appears to include the scroll-to-row animation). Doing this will give you the appearance of a true completion.

func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
    DispatchQueue.main.async {
        // execute subsequent animation here
    }
}
trndjc
  • 11,654
  • 3
  • 38
  • 51
0

Implementing this in a Swift extension.

//strong ref required
private var lastDelegate : UITableViewScrollCompletionDelegate! = nil

private class UITableViewScrollCompletionDelegate : NSObject, UITableViewDelegate {
    let completion: () -> ()
    let oldDelegate : UITableViewDelegate?
    let targetOffset: CGPoint
    @objc private func scrollViewDidEndScrollingAnimation(scrollView: UIScrollView) {
        scrollView.delegate = oldDelegate
        completion()
        lastDelegate = nil
    }

    init(completion: () -> (), oldDelegate: UITableViewDelegate?, targetOffset: CGPoint) {
        self.completion = completion
        self.oldDelegate = oldDelegate
        self.targetOffset = targetOffset
        super.init()
        lastDelegate = self
    }
}

extension UITableView {
    func scrollToRowAtIndexPath(indexPath: NSIndexPath, atScrollPosition scrollPosition: UITableViewScrollPosition, animated: Bool, completion: () -> ()) {
        assert(lastDelegate == nil, "You're already scrolling.  Wait for the last completion before doing another one.")
        let originalOffset = self.contentOffset
        self.scrollToRowAtIndexPath(indexPath, atScrollPosition: scrollPosition, animated: false)

        if originalOffset.y == self.contentOffset.y { //already at the right position
            completion()
            return
        }
        else {
            let targetOffset = self.contentOffset
            self.setContentOffset(originalOffset, animated: false)
            self.delegate = UITableViewScrollCompletionDelegate(completion: completion, oldDelegate: self.delegate, targetOffset:targetOffset)
            self.scrollToRowAtIndexPath(indexPath, atScrollPosition: scrollPosition, animated: true)
        }
    }
}

This works for most cases although the TableView delegate is changed during the scroll, which may be undesired in some cases.

Drew
  • 8,675
  • 6
  • 43
  • 41