From the blog

Fixing Controls in Scroll Views on iOS

Buttons in scroll views have a subtly broken behavior. In this post, we show you a simple workaround to keep your UI feeling smooth and consistent with the design language of iOS.

You can download the sample project for this article if you’d like to try it for yourself.

Buttons on iOS change color when you press your finger down on them, which provides visual feedback to confirm which button will be activated when you lift your finger. This also gives you the chance to change your mind: If you drag your finger off the button, the touch will be canceled when you lift up, and this is indicated by the button’s un-highlighting.

A single, non-scrolling button. After pressing down on it, dragging outside the button and letting go cancels the touch.
A single, non-scrolling button. After pressing down on it, dragging outside the button and letting go cancels the touch.

Table and collection view cells behave similarly, in that they highlight when you press on them, and dragging cancels the tap. However, dragging also drags the table view. This is a nice touch, because table views are typically very responsive on iOS, and trying to scroll a table view that’s stuck can make an app feel broken.

Pressing a table view cell will highlight it, and dragging will cancel the touch and un-highlight the cell.
Pressing a table view cell will highlight it, and dragging will cancel the touch and un-highlight the cell.

Default table and collection view cells work like this out of the box, but if you add custom controls to your cells, or are adding controls to a UIScrollView directly, they will eat any touches that cause them to be highlighted, preventing those touches from reaching the scroll view. This, in turn, prevents the scroll view from scrolling if the user drags away from the button. You can see this behavior in the example on the left:

The normal scroll view, left, does not scroll if the button gets the touch first. The control-containable scroll view, right, does not have this problem, and therefore feels much more natural.
The normal scroll view, left, does not scroll if the button gets the touch first. The control-containable scroll view, right, does not have this problem, and therefore feels much more natural.

Fortunately, it’s easy to get the behavior pictured on the right. Just add these three classes to your project, and use them wherever you would normally use a UIScrollView, UITableView, or UICollectionView. The trick is overriding touchesShouldCancel(in:) to allow the scroll view to cancel touches in controls.

import UIKit

// These let you start a touch on a control that's inside a scroll view,
// and then if you start dragging, it cancels the touch on the button
// and lets you scroll instead. Without these scroll view subclasses,
// controls in scroll views will eat touches that start in them, which
// prevents scrolling and makes the app feel broken.
//
// The UITextInput exception is for cases where you have a text field
// or a text view in a scroll view. If you press and hold there, you want
// to get the text editing magnifier cursor, instead of canceling the
// touch in the text input element.
//
// Ditto for UISlider and UISwitch: if the table view eats the drag gesture,
// they feel broken. Feel free to add your own exceptions if you have custom
// controls that require swiping or dragging to function.

final class ControlContainableScrollView: UIScrollView {

    override func touchesShouldCancel(in view: UIView) -> Bool {
        if view is UIControl
            && !(view is UITextInput)
            && !(view is UISlider)
            && !(view is UISwitch) {
            return true
        }

        return super.touchesShouldCancel(in: view)
    }

}

final class ControlContainableTableView: UITableView {

    override func touchesShouldCancel(in view: UIView) -> Bool {
        if view is UIControl
            && !(view is UITextInput)
            && !(view is UISlider)
            && !(view is UISwitch) {
            return true
        }

        return super.touchesShouldCancel(in: view)
    }

}

final class ControlContainableCollectionView: UICollectionView {

    override func touchesShouldCancel(in view: UIView) -> Bool {
        if view is UIControl
            && !(view is UITextInput)
            && !(view is UISlider)
            && !(view is UISwitch) {
            return true
        }

        return super.touchesShouldCancel(in: view)
    }

}

(Edit: added UISlider and UISwitch exceptions at @calebd’s suggestion.)

We use this trick in all our iOS apps here at Raizlabs. It’s the kind of thing users never notice, but, like smarter animated row deselection, it sands off a rough edge and removes a tiny bit of friction between your user and a great experience.

Leave a Reply

Your email address will not be published. Required fields are marked *