How to trigger actions on swipe in UITableViewCell using UIScrollView

Recently, I wanted to add quick actions to my UITableViewCells, not with buttons but with pan gesture, such as the ones you can find in many mail apps.

UIScrollView was the best and simplest approach. First, you’ll see how to setup a UIScrollView in a UITableViewCell, to reveal a custom view for actions. Then you’ll see how to perform actions through the pan gesture by implementing the UIScrollViewDelegate.

1. UITableViewCell setup

First, create a subclass of UITableViewCell. The container view will be our cell content. Action view and label will be the views revealed on scroll.

class SwipeCell: UITableViewCell {
var scrollView: UIScrollView!
var containerView: UIView!
var actionView: UIView!
var actionLabel: UILabel!
}

In a method called in our initializers, we instantiate this views and add them to the view hierarchy as following:

contentView
scrollView
actionView
actionLabel
containerView

For the scrollView, we want to hide the scroll indicators. We want also a fast deceleration for when the user ends dragging. Note that we need to set the contentInset in order to display the actionView in the padding of the scrollView.

func setup() {
scrollView = UIScrollView(frame: bounds)
scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
scrollView.contentSize = bounds.size
scrollView.contentInset = UIEdgeInsets(
top: 0, left: bounds.width, bottom: 0, right: bounds.width)
scrollView.delegate = self
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.decelerationRate = UIScrollViewDecelerationRateFast
contentView.addSubview(scrollView)

actionView = UIView(frame: CGRect(origin: .zero, size: bounds.size))
actionView.backgroundColor = UIColor.gray
actionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
scrollView.addSubview(actionView)

actionLabel = UILabel(frame:
CGRect(x: 10, y: 0, width: bounds.width - 20, height: bounds.height))
actionLabel.font = UIFont.systemFont(ofSize: 12)
actionLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight]
actionLabel.textColor = .white
actionView.addSubview(actionLabel)

containerView = UIView(frame: scrollView.bounds)
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.backgroundColor = UIColor.lightGray
scrollView.addSubview(containerView)
}

To make sure the scrollView inset and size are set correctly when the cell appears, we should also update them in the layoutSubviews method.

override func layoutSubviews() {
super.layoutSubviews()
scrollView.contentInset = UIEdgeInsets(top: 0, left: bounds.width, bottom: 0, right: bounds.width)
scrollView.contentSize = contentView.bounds.size
}

2. Revealing the action view

If you try this code, you’ll notice that the scrollView works. However on scroll, the actionView doesn’t appear. In fact, it scrolls along the containerView.

To enable this behavior, we have to update the actionView frame in the scrollViewDidScroll method. By updating its x with the contentOffset, you make sure the actionView never moves and gets revealed when the containerView does.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetX = scrollView.contentOffset.x
actionView.frame = CGRect(origin: CGPoint(x: offsetX, y: 0), size: actionView.frame.size)
}

3. Scrolling animation

When the user swipes more than a minimum offset (here 50px) and ends the gesture, we want to fully reveal the actionView. For that we need to detect the swipe direction and implement some UIScrollViewDelegate methods.

With a scrollview, you can find the scroll direction by checking contentOffset value. So here are utility attributes to find out which side is visible when the user is scrolling.

let minOffset: CGFloat = 50

var isLeftSideVisible:Bool {
return scrollView.contentOffset.x < 0
}
var isRightSideVisible:Bool {
return scrollView.contentOffset.x > 0
}

« scrollViewWillEndDragging » is called when the user finishes scrolling the content. We can change value of the « targetContentOffset » parameter to adjust where the scrollview finishes its scrolling animation. In our case

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

let offsetX = abs(scrollView.contentOffset.x)
let width = scrollView.contentSize.width

if offsetX < minOffset {
targetContentOffset.pointee = .zero
}else{
if isLeftSideVisible {
targetContentOffset.pointee.x = -width
} else if isRightSideVisible {
targetContentOffset.pointee.x = width
}
}
}

For now, when the animation ends, we want to go back to the initial position.

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollView.setContentOffset(.zero, animated: false)
}

4. Detecting action on scroll

When the user scrolls more than the minimum offset, we can start showing the action title and its background color. But I wanted to handle several actions on my cells, so if the user scrolls further, I wanted to show another one (I was inspired by the Polymail app).

Two actions on one side should be enough, having more can confuse users. But technically, we should not limit ourselves to two and should use an array.

protocol SwipeAction {   
var color: UIColor { get }
var title: String { get }
}
var leftActions: [SwipeAction] = []
var rightActions: [SwipeAction] = []

Here is a method to retrieve the action (if any) from the contentOffset. We basically split the width into the number of actions and retrieve the index with the contentOffset.

func findVisibleAction() -> SwipeAction? {

let offsetX = abs(scrollView.contentOffset.x)
let actionsWidth = scrollView.contentSize.width - minOffset

if offsetX < minOffset {
return nil
}
let relativeX = offsetX - minOffset
if isLeftSideVisible {
let singleActionWidth = actionsWidth / CGFloat(leftActions.count)
let i = max(0, Int(relativeX / singleActionWidth))
return i < leftActions.count ? leftActions[i] : nil
}
if isRightSideVisible {
let singleActionWidth = actionsWidth / CGFloat(rightActions.count)
let i = max(0, Int(relativeX / singleActionWidth))
return i < rightActions.count ? rightActions[i] : nil
}
return nil
}

5. Showing action on scroll

When the view ends dragging, we want to save the action the user selected.

var selectedAction: SwipeAction?

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
selectedAction = findVisibleAction()
}

When the view scrolls, we still change the actionView frame as seen previously, but we also setup the actionView with the selected action, or the visible one (if the user is still dragging).

func scrollViewDidScroll(_ scrollView: UIScrollView) {

let offsetX = scrollView.contentOffset.x
actionView.frame = CGRect(origin: CGPoint(x: offsetX, y: 0), size: actionView.frame.size)

let action = selectedAction ?? findVisibleAction()
actionView.backgroundColor = action?.color ?? UIColor.lightGray
actionLabel.textAlignment = isRightSideVisible ? .right : .left
actionLabel.text = action?.title
}

6. Performing action

When the animation ends, if an action was selected, you can call a method to perform it. Otherwise you can dismiss the action view by setting the content offset to zero.

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let action = selectedAction {
performAction(action)
} else {
dismissActionView()
}
}
func dismissActionView() {
selectedAction = nil
scrollView.setContentOffset(.zero, animated: false)
}

Don’t forget to unset the selected action when the user starts dragging again!

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
selectedAction = nil
}

In the end, in less than 150 lines of code, you end up having a simple and customizable way to handle actions on UITableViewCells.

You can find the final code here: https://gist.github.com/AdrienCog/9baf1489d507ff282ecbdc29d61e2b78