Smarter Animated Row Deselection on iOS

It’s always a good idea to give your users context: at a glance, they should be able to figure out where they are, how they got there, and how to get back where they were before. One subtle but important way to give these cues is with animated deselection.

Default Desel

If you’d like, download the sample code for this post and follow along at home. This post refers to UITableViewController, but it also works with plain table and collection views.

Default Deselection

Here we see an app that uses UITableViewController to show a list of items. When you tap a row, it gets a gray selection highlight. Also called “pressed states,” these are a great way to give immediate, tactile feedback that an app is working, so don’t forget to add them! If they’re missing, and the app doesn’t respond instantaneously to every tap, your users will either think that the app is broken, or worse, that they did something wrong.

Pay attention to what happens when you tap the Back button. Notice that, as the original list moves back onto the screen, the tapped row still has the gray highlight, and it fades out over the duration of the animated transition. This is subtle, and most users probably never notice it, but it provides an important visual cue as to where you just came from. For example, if you’re going down a list and viewing details on each item, that un-highlight animation helps you avoid losing your place and tapping the same item twice.

If you’re using UITableViewController, you get this behavior “for free”; that is, it will automatically deselect the selected row during a “back” navigation event. However, in some cases you won’t get automatic deselection, and even when you do, it doesn’t work correctly with the interactive, percent-driven animations that were introduced in iOS 7, including the one built into UINavigationController. Watch what happens when I interactively dismiss this screen by sliding my thumb from the left side of the screen, a common gesture among users of iPhone 6 and iPhone 6 Plus devices:

Default Deselection Interactive Looping

The resulting behavior is at best suboptimal, and at worst downright broken. When I use an interactive gesture to pop the navigation stack, the row either waits until the animation is done to un-highlight, or it just gets stuck and never un-highlights. It would be better if the row faded out smoothly as a function of the transition progress.

Naïve Deselection

Fortunately, animations triggered from within UIViewController’s viewWillAppearmethod will pick up the surrounding animation context’s properties. All we need to do is ask the table view to deselect its selected rows with animated: true, and the deselection animation will magically move forward and backward with the progress of the interactive gesture. The code in the master view controller (the one that contains the table view) looks like this:

Don’t worry if your Swift is a little rusty. Here’s what it says: for each of the index paths in the array of the table view’s selected index paths, ask the table view to deselect that index path with animation enabled. The $0 refers to the argument passed into the forEach closure, which, in this case, is an NSIndexPath.

Here’s how it looks:

Naïve Deselection

Pretty cool! The cell deselection animation is a function of how far the detail view controller has slid off the screen. But hang on, what’s happening when I start an interactive dismiss animation, and then change my mind and cancel it? viewWillAppear is still getting called, so the cells are being deselected, but if I cancel the transition, the cells aren’t reselected.

Smart Deselection

What we need is a way to reselect the cells if the dismissal is canceled. Fortunately, there’s a way to do what we need using the view controller’s transition coordinator. I’m implementing it here as an extension on UIViewController so that it’s easy to call without cluttering up your viewWillAppear method:

 

Finally, deselection happiness:

Smart Deselection

When the transition is canceled, the cells are reselected so they can interactively unhighlight all over again during the next attempt at a transition.

Details like this are fiddly, and invisible to most people if you get them right. But if they’re missing, users will notice, or be confused, or both. Luckily, now you don’t have to work hard to sweat this particular detail. You can see the commented sample code here, but the easiest way to add it to your own projects is to grab it from the Raizlabs Swift grab-bag, Swiftilities. There is also a gist which contains both Swift and Objective-C versions of this code, if you’d prefer to just download it and drop it into your project. Thanks to Brad Smith for helping me figure this stuff out.


Do you have a project in mind? We’d love to work with you. If you’d like an opportunity to work on projects with us, check out our Careers page. We’re hiring!

19 thoughts on “Smarter Animated Row Deselection on iOS”

  1. Please take a look at https://gist.github.com/chebur/bfaddbb00232d3fd8ae0c6c28b9ebaf1
    I’m using notification callback. What do you think?

  2. Hi! Nice post. Two things:

    1. The word “here” above is linked to https://github.com/Raizlabs/sample-smart-animated-deselection-ios which is a 404.
    2. There is no license on the Gist, can it be used freely?

    Thanks!

  3. I was using a solution like this before, but found a problem with it… With this implementation, in case when some table view contents before the selected row gets inserted or deleted during the interactive dismiss gesture, invalid cell gets selected back. But the only abstract solutions I thought of so far are nasty at least …

  4. @skagedal 1. nice catch. It was a private repo. 2. Feel free to use it! I added the MIT license to make it official.

    @Jakub that’s a good point. It’s going to be hard if not impossible to write an abstraction that would handle cases with insertion/deletion. Your best bet is to use this code as a template, but add special cases for those row changes. If you do find a way to make it more flexible, let me know or submit a pull request to Swiftilities!

  5. @zev Cool, thanks! You may also want to fix the bug in the Objective C code I mention in the comments on the gist. 🙂

  6. ????

  7. In your viewWillAppear solution, why not just change the deselection to be in viewDidLoad? Seems like a simpler solution than what you eventually came up with.

  8. @Adam viewDidLoad happens only the first time the view is loaded into memory. If you navigate away and then back, it may still be loaded into memory, and so it won’t get viewDidLoad called again. Remember, this code goes in the parent view controller, not the detail view controller.

  9. One thing I noticed while using this (which unfortunately made it unusable for me), is that when you have a larger than normal bar button item, say 44×44 ,in the parent view’s leftBarButtonItem slot, if you go about halfway through the interactive gesture and then back to the “child” view…your native back button then becomes stretched out and gross looking. Probably could be solved by not having a larger button on the parent view, didn’t test that out, but could be an indicator of possible future bugs you’d run into with this.

  10. @Josh Woods thanks for pointing that out. Sounds like it could possibly be a UIKit bug. I wonder whether calling -setNeedsLayout or -sizeToFit on the button item’s customView would help? Or perhaps setting its frame explicitly?

  11. It’s fucking awesome.

  12. But it seems that
    selectedIndexPaths.forEach {
    tableView?.deselectRowAtIndexPath($0, animated: false)
    }

    leaks the selectedIndexPaths

  13. @Oleksii leaks? As in, memory leak? I don’t see how that’s possible.

  14. That’s what Memory Leak instrument was showing to me. It went away when I changed it to for … in. There might be a reference cycle if $0 stores that block of foreEach. But that seems odd.

  15. Also, calling animateAlongsideTransitionInView on parentViewController‘s view causes a bug:
    when I interactively pop (drag finger to the right) from view controller that has navigation bar hidden to controller that has navigation bar visible – but then cancel the interactive pop (like drag finger back all the way to the left) – the navigation bar blinks for a moment.

    Everything works fine when i use animateAlongsideTransition without specifying a view

  16. @Oleksii I couldn’t reproduce the memory leak or the flashing navigation bar (because I don’t know exactly how your app is handling the navigation bar stuff), but I confirmed that your animateAlongsideTransition fix works, so I’ve updated the sample code, the blog post, and submitted a pull request to Swiftilities. I also switched the enumerations to use for…in instead of closure-based enumeration, even though I don’t see how it could be leaking, since it’s theoretically slightly faster this way. Thanks for your comments!

  17. Pingback: Fixing Controls in Scroll Views on iOS - RaizException - Raizlabs Developer BlogRaizException – Raizlabs Developer Blog

  18. thank you, very useful, I’ve created a swift 3 version, here: https://gist.github.com/matoelorriaga/dfe84393e258542f83883fac1c33f41d

  19. @Matías Elorriaga cool! We also maintain an up-to-date version here: https://github.com/Raizlabs/Swiftilities/blob/develop/Pod/Classes/Deselection/UIViewController%2BDeselection.swift

Leave a Comment