Programmatically Create a Swipeable UIScrollView with Embedded UIButtons

Programmatically Create a Swipeable UIScrollView with Embedded UIButtons

Intercept touch events on embedded UIButtons to allow standard swiping. Use only constraints and auto layout to size all control components.

Go to the profile of  Jonathan Banks
Jonathan Banks
5 min read
Time
30mins
Platforms
iOS
Difficulty
Easy>Medium
Technologies
Swift

Creating a UIScrollView with a set of embedded UIButtons using only code should be a straightforward coding exercise. The reality is that it is a tricky procedure fraught with potential pitfalls.

In order to troubleshoot a non-scrolling UIScrollView, consider the following:

  • Look into any objects that could consume touch events
  • Check that contentSize is correctly set for scrolling
  • Examine autolayout configuration and make sure it is not interfering with the code

Overview

This guide will provide you with everything required to achieve a collapsible and scrollable UIScrollView that is the parent to a set of embedded UIButtons. Refer to the animation below:

ezgif-6-31b4c4c3ed0a

This is strictly a functional example, not for aesthetics. Making it beautiful is left as an exercise for the reader.

The key to the correct operation of this control is dragging from any UIButton should make the UIScrollView move. When the user taps to select the button, it should also function normally.

Create the Custom UIScrollView

First, extend UIScrollView to a custom class named SwipeableUIScrollView. This is required to override one critical function, touchesShouldCancel(in view: UIView).

Create a new file and insert the following:

class SwipeableUIScrollView: UIScrollView {

	
	override func touchesShouldCancel(in view: UIView) -> Bool {
		
		if view is UIButton || view is UILabel{
			return true
		}
		
		return touchesShouldCancel(in: view)
	}
	
}

What's happening in that touchesShouldCancel(in view:) method?

When the user touches and swipes from a UIButton the overridden code in touchesShouldCancel(in view:) permits the SwipeableUIScrollView to own the user swipe. It prevents UIButtons and UILabels from subverting the intended operation and consuming the touch.

In other words, swipes that occur on embedded UIButtons and UILabels will be used by the UIScrollView instead.

If there is still confusion, this excerpt from [Apple's documentation] (https://developer.apple.com/documentation/uikit/uiscrollview/1619387-touchesshouldcancel) may help to clarify:

The scroll view calls this method just after it starts sending tracking messages to the content view. If it receives false from this method, it stops dragging and forwards the touch events to the content subview. The scroll view does not call this method if the value of the canCancelContentTouches property is false.

ViewController with Programmatic UIScrollView

The entire source for the main UIViewController is embedded below. Skim the code to get a basic overview of the various components.

class ViewController: UIViewController {

    var scrollView:SwipeableUIScrollView!
	var greenView:UIView!
	var dropDownButton:UIButton!
	
	var scrollViewHeightConstraint:NSLayoutConstraint!
	
    override func loadView() {
		super.loadView()
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
		scrollView = SwipeableUIScrollView.init(frame: CGRect.zero)
		scrollView.translatesAutoresizingMaskIntoConstraints = false
		
		greenView = UIView.init(frame: CGRect.zero)
		greenView.translatesAutoresizingMaskIntoConstraints = false
		greenView.backgroundColor = UIColor.green
		
		dropDownButton = UIButton.init(frame: CGRect.zero)
		dropDownButton.translatesAutoresizingMaskIntoConstraints = false
		dropDownButton.backgroundColor = UIColor.red
		dropDownButton.setTitle("Trigger", for: .normal)
		
		dropDownButton.addTarget(self, action: #selector(showHideScrollView(_:)), for: .touchUpInside)
		
		self.view.addSubview(scrollView)
		self.view.addSubview(greenView)
		self.view.addSubview(dropDownButton)
		
		
		scrollViewHeightConstraint = NSLayoutConstraint.init(item: scrollView, attribute: .height, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 50.0)
		
		NSLayoutConstraint.activate([
			dropDownButton.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 50.0),
			dropDownButton.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 15.0),
			dropDownButton.heightAnchor.constraint(equalToConstant: 25.0),
			dropDownButton.widthAnchor.constraint(equalToConstant: 100.0),
			
            scrollView.topAnchor.constraint(equalTo: self.dropDownButton.bottomAnchor, constant: 10.0),
			scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
			scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
			scrollViewHeightConstraint,
			
            greenView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
			greenView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
			greenView.topAnchor.constraint(equalTo: self.scrollView.bottomAnchor),
			greenView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
			
		])
	   
		var leadingAnchor = self.scrollView!.leadingAnchor
	   
		for i in 0..<20{
		   
			let t_button = UIButton.init(frame: CGRect.zero)
			t_button.translatesAutoresizingMaskIntoConstraints = false
		   
			
			t_button.backgroundColor = UIColor.blue
			scrollView.addSubview(t_button)
		   
			NSLayoutConstraint.activate([
				t_button.leadingAnchor.constraint(equalTo: leadingAnchor, constant:5.0),
				t_button.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
				t_button.heightAnchor.constraint(equalToConstant: 50.0),
				t_button.widthAnchor.constraint(equalToConstant: 75.0)
			])
		   
			leadingAnchor = t_button.trailingAnchor
			
			t_button.setTitle("Button \(i)", for: .normal)
			t_button.addTarget(self, action: #selector(scrollViewButtonAction(_:)), for: .touchUpInside)
		   
		}
           
		self.scrollView.trailingAnchor.constraint(equalTo: leadingAnchor).isActive = true
			  
    }


	@objc func scrollViewButtonAction(_ sender:Any?){
		
		if let t_sender = sender as? UIButton{
			
			NSLog("Selected button: \(t_sender.currentTitle!)")
		}
		
	}
	
	
	@objc func showHideScrollView(_ sender: Any?){
		
		if self.scrollViewHeightConstraint.constant == 0{
			UIView.animate(withDuration: 0.5) {
				self.scrollViewHeightConstraint.constant = 50.0
				self.view.layoutIfNeeded()
			}
		}else{
			UIView.animate(withDuration: 0.5) {
				self.scrollViewHeightConstraint.constant = 0.0
				self.view.layoutIfNeeded()

			}
		}
		
		
	}
}



ViewController Code Breakdown

The detailed breakdown of the UIViewController code will focus only on the portions that are required for proper scrolling of the UIScrollView.

Configure UIScrollView

First, an instance of the custom UIScrollView is created programmatically.

scrollView = SwipeableUIScrollView.init(frame: CGRect.zero)
scrollView.translatesAutoresizingMaskIntoConstraints = false

self.view.addSubview(scrollView)

NSLayoutConstraint.activate([
   scrollView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 50.0),
   scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
   scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
   scrollView.heightAnchor.constraint(equalToConstant: 50.0)

])
        

Notice that the constraints are activated and set the leading and trailing bounds to the UIViewController's view but set a height of 50 for the control.

Create UIButtons and Set ContentSize

var leadingAnchor = self.scrollView!.leadingAnchor
	   
for i in 0..<20{

    let t_button = UIButton.init(frame: CGRect.zero)
    t_button.translatesAutoresizingMaskIntoConstraints = false


    t_button.backgroundColor = UIColor.blue
    scrollView.addSubview(t_button)

    NSLayoutConstraint.activate([
        t_button.leadingAnchor.constraint(equalTo: leadingAnchor, constant:5.0),
        //t_button.topAnchor.constraint(equalTo:scrollView.topAnchor, ),
        t_button.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
        t_button.heightAnchor.constraint(equalToConstant: 50.0),
        t_button.widthAnchor.constraint(equalToConstant: 75.0)
    ])

    leadingAnchor = t_button.trailingAnchor

    t_button.setTitle("Button \(i)", for: .normal)
    t_button.addTarget(self, action: #selector(scrollViewButtonAction(_:)), for: .touchUpInside)

}

The code adds a sequence of twenty buttons to the UIScrollView. The critical area is the assignment of the constraints for each button. The code sets the leadingAnchor variable to the leadingAnchor of the scrollView. The leadingAnchor acts as the "pointer" set equal to the trailingAnchor of each newly created UIButton.

Examine the following line:

leadingAnchor = t_button.trailingAnchor

The loop code sets the leadingAnchor to the trailingAnchor of each UIButton. The leadingAnchor is set to the final button's trailingAnchor on the last loop.

The loop exit triggers the following code:

self.scrollView.trailingAnchor.constraint(equalTo: leadingAnchor).isActive = true

The code sets the tail of the UIScrollView to the tail end of the last UIButton. The contentSize is automatically calculated by autolayout because the UIScrollView finally has a point well beyond the screen bounds to anchor itself. It can define its entire scrollable area from the inside out.

In other words, the width and height of the embedded UIButton set, added together, is automatically defined as the contentSize of the scrollable area by autolayout.

Wrapup

The code will result in a slim, horizontally scrolling UIScrollView but it can be easily adapted for vertical scrolling.

Instead of adding buttons horizontally, as in the sample code, add them vertically and set the bottomAnchor of the UIScrollView to the bottomAnchor of the last UIButton.

When you set the bottomAnchor on the final loop iteration, autoLayout knows the scrollable area instantly, regardless of vertical or horizontal scrolling.