Animated Dropdown Selector

Animated Dropdown Selector

Compose a custom animated control using UITableVIew, UITextField, and UIImageView

Go to the profile of  Jonathan Banks
Jonathan Banks
11 min read
Time
2hours
Platforms
iOS
Difficulty
Medium
Technologies
Swift

Learn how to build a composite iOS control by combining a set of standard controls into a single class. Follow step-by-step instruction with detailed code descriptions to supplement the downloadable project code.

Overview

Building complex iOS applications will eventually require designing custom controls.  There are many paths to choose when tackling advanced interactive controls, in order of least complex to most:  

  • Subclass an existing control and customize/add functionality
  • Compose a control out of several existing, stock controls
  • Use CoreGraphics, touch handlers, overridden draw function, and more, to build and wire the control from scratch

Composing a control from multiple controls is powerful because it provides flexibility with a small amount of risk. Apple tests the built-in controls thoroughly, across operating systems and devices. Thus, composing them should not introduce unknown bugs.

Three main controls compose the proposed animated menu dropdown selector built in the following sections: UITextField, UIImageView, and UITableView. Refer to the animation below to see what the final product looks like:

Setup Project

Create a new project by navigating to File->New Project in XCode, selecting a new Single View app, and name the project SelectorTextView.

Screen-Shot-2019-07-15-at-11.18.26-PM

The XCode environment is complete with a ViewController, AppDelegate, Storyboard, and more. Next, navigate to File->New File, select Cocoa Touch Class, and name the new file SelectorTextView. Set it as a subclass of UIView.

Screen-Shot-2019-07-15-at-11.23.43-PM

Create a final Cocoa Touch file named SelectorCell and make it a subclass of UITableVIewCell.

Refer to the project code located here for the subsequent sections.

Initial Code

The custom control is a composition of UITextField, UIImageView, and UITableView. What is the best strategy to position these distinct controls together as a single unit?

The solution is to add one additional UIView as a container that will hold the controls and serve as the parent to all the control subviews.

    private var tableView: UITableView!
    private var textField: UITextField!
    private var dropDownArrow:UIImageView!
    private var containerView:UIView!

The existing controls have touch interaction enabled but that aspect is tweakable and much of it discarded in the final form. Disable the built-in tap interaction by programmatically adding UITapGestureRecognizers to the controls targeted for overriding. These gesture recognizers function solely to intercept the tap touches.

Attach one tap gesture to the UITextField and the other to the UIImageView in order to cover all tap interactivity with the control.

    private var textViewGesture:UITapGestureRecognizer!
    private var dropDownGesture:UITapGestureRecognizer!

Another design challenge comes to mind. How will the control know when the dropdown is open or closed so it can trigger the correct animations? To cover this, create an enum with "opened" and "closed" options and private variables of the enum type that will dynamically hold the current status. Refer to the code:

     private enum Status{
        case opened
        case closed
    }
    
    private var status:Status!

The UITableVIew needs a datastore to hold the options available for the user to select. There are also miscellaneous cosmetic options such as the height of the cells in the table, initial text shown to the user, and component colors to consider. Set these at the class level for simplicity. Refer to the code:

    private var tableViewData:[String] = ["Basketball", "Football", "Soccer", "Baseball", "Hockey" ]
    var cellHeight:CGFloat = 40.0
    var initialText:String = "Select a Sport"
    var dividerThickness:CGFloat = 5.0
    var dividerColor:UIColor = UIColor.init(hexString: "ebeced")
    var arrowColor:UIColor = UIColor.init(hexString: "b5b5b5")

Finally, upload the image for the dropdown arrow into assets.xcassets as a new image set named "dropdown". Select the image and set it to render as a Template Image in the Attributes Inspector. Adjusting the tint for the image now changes the color.

Screen-Shot-2019-07-16-at-12.02.52-AM-1

Initialize and Position Components

The overridden init function instantiates the component controls and uses constraints to position them within the container UIView by calling the initializeComponents and positionComponents functions.

override init(frame: CGRect) {
    super.init(frame:frame)

    self.bounds.size = CGSize(width: self.frame.width, height: self.bounds.height + (CGFloat(tableViewData.count) * cellHeight))

    initializeComponents()
    positionComponents()

    textViewGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
    textField.addGestureRecognizer(textViewGesture)
    dropDownGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
    dropDownArrow.addGestureRecognizer(dropDownGesture)

    status = Status.closed
        
        
    }

Setting the bounds is critical to the operation of this custom control. When the control is dormant, it will take the same space as a single UITextField. When the user taps on it the UITableView will be revealed using animation to drop the drawer down. Thus, the bounds need to be updated to account for the UITableView and its total height with all possible cells.

self.bounds.size = CGSize(width: self.frame.width, height: self.bounds.height + (CGFloat(tableViewData.count) * cellHeight))

This line of code sets the size of the bounds of the control. It uses a CGSize that's the frame width and a calculated height. The calculation for the height includes the height of the bounds of the control plus the total height of the UITableView.

Calculating the height requires multiplying the total count of items in the data source, tableViewData, by the height of each cell stored in cellHeight.

The UITableView will not receive the tap input if there are incorrect bounds calculations. The touches will fall outside the bounds of the entire control.

Lastly, add a UITapGestureRecognizer to textField and dropDownArrow. Refer to the code:

 textViewGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
        textField.addGestureRecognizer(textViewGesture)
        dropDownGesture = UITapGestureRecognizer.init(target: self, action:  #selector(triggerTable))
        dropDownArrow.addGestureRecognizer(dropDownGesture)
        
        status = Status.closed

These intercept the user interaction to the SelectorTextField to trigger animations and change the text in the textField to reflect user selection.

Initialize Components

The two main functions in init are initializeComponents and positionComponents. initializeComponents instantiates and configures each sub-component of the control with a frame of size zero. The constraints provide the actual size to the control so defining a frame is unnecessary.

For the code that initializes the components make sure the tableView.delegate and tableView.datasource are set to self since the UITableViewDelegate and UITableViewDataSource protocols will be implemented inside the class. They provide data and content for the tableview. Each control is added to the SelectorTextField using self.addSubview().

Refer to the code:

private func initializeComponents(){
        

    // Tableview
    tableView = UITableView(frame: CGRect.zero)
    tableView.register(SelectorCell.self, forCellReuseIdentifier: "selectorcell")
    tableView.bounces = false
    tableView.backgroundColor = UIColor.init(hexString: "ebeced")
    tableView.separatorInset = UIEdgeInsets.zero
    tableView.separatorColor = UIColor.clear
    tableView.delegate = self
    tableView.dataSource = self
    tableView.tableFooterView = UIView(frame:CGRect.init(x: 0, y: 0, width: 1, height: 1))

    self.addSubview(tableView)

    //Container
    containerView = UIView(frame:.zero)


    //TextField
    textField = UITextField(frame: .zero)
    textField.text = initialText
    // textField.backgroundColor = UIColor.blue
    textField.font = UIFont.systemFont(ofSize: 18, weight: UIFont.Weight.medium)
    containerView.addSubview(textField)

    //Dropdown Arrow
    dropDownArrow = UIImageView.init(frame: .zero)
    dropDownArrow.image = UIImage(named: "dropdown")
    dropDownArrow.isUserInteractionEnabled = true
    dropDownArrow.tintColor = arrowColor
    containerView.addSubview(dropDownArrow)

    self.addSubview(containerView)
}

Notice that container.addSubView() is used for the UITextField and dropDownArrow. These are positioned inside the container. self.addSubView() is subsequently used to add the containerView and the tableView to the SelectorTextField. That completes assembly of the components for the control.

Coding the tableFooterView to the tableView is a cosmetic decision. Adding a tiny 1x1 footer to the bottom prevents drawing the last cell's separator/divider.

Position Components

Critical to any manually manipulated constraints is setting the translatesAutoresizingMaskIntoConstraints flag on each component to false. Forgetting to set this flag on one or more controls is a common error.

First, set up the constraints for the container. Refer to the code:

//Container
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant:10.0).isActive = true
containerView.topAnchor.constraint(equalTo: topAnchor).isActive = true
containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant:-10.0).isActive = true
containerView.heightAnchor.constraint(equalToConstant: cellHeight).isActive = true
        

Breaking the containerView constraints down:

  • containerView top, leading, and trailing anchors: set to the parent's anchors. Those are the SelectorTextField and the frame plus the external constraints from the ViewController.
  • heightAnchor: equal to cellHeight to keep the cells and the SelectorTextField the same height.

Next, set up the textField constraints by positioning it to the left of the dropDownArrow. Refer to the code:

//Textfield
    textField.translatesAutoresizingMaskIntoConstraints = false
    textField.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant:10.0).isActive = true
    textField.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
    textField.trailingAnchor.constraint(equalTo: dropDownArrow.leadingAnchor).isActive = true
    textField.heightAnchor.constraint(equalToConstant: cellHeight).isActive = true

Breaking the textField constraints down:

  • height: set equal to the containerView that is itself equal to the cellHeight
  • leadingAnchor: add a small amount of space on the leading constraint to let the control have some breathing room
  • trailingConstraint: set to the dropDownArrow's leadingAnchor since it will come before the arrow within the containerView.

The dropDownArrow is set up similarly to the textField except the width and height are set smaller. The rationale is to animate it and have the rotation stay within the bounds of the container. Refer to the code:

//Dropdown
    let arrowWidthHeight = cellHeight/2
    dropDownArrow.translatesAutoresizingMaskIntoConstraints = false
    dropDownArrow.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant:-10.0).isActive = true
    dropDownArrow.topAnchor.constraint(equalTo: containerView.topAnchor, constant:arrowWidthHeight/2).isActive = true
    dropDownArrow.heightAnchor.constraint(equalToConstant: arrowWidthHeight).isActive = true
    dropDownArrow.widthAnchor.constraint(equalToConstant: arrowWidthHeight).isActive = true
        

Notice the arrowWidthHeight is set to 1/2 the cellHeight and the control is positioned lower in the containerView. This permits centering at the smaller size.

The final control to position is the tableView. Refer to the code:

    //Tableview
    tableView.translatesAutoresizingMaskIntoConstraints = false
    tableView.leadingAnchor.constraint(equalTo: leadingAnchor, constant:15.0).isActive = true
    tableView.trailingAnchor.constraint(equalTo: trailingAnchor, constant:-15.0).isActive = true
    tableView.topAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true

    // Round the edges of the dropdown table
    tableView.clipsToBounds = true
    tableView.layer.cornerRadius = 10
    tableView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]

    tableHeightConstraint = tableView.heightAnchor.constraint(equalToConstant: 0.0)
    tableHeightConstraint.isActive = true

The tableView is placed directly below the containerView. The intention is to give it a height of 0 and store the constraint as a class variable for animation. tableHeightConstraint is that constraint and is accessible to other functions in the class.

The last step draws an aesthetically appealing border under the control.

//Add border to container
containerView.layoutIfNeeded()
containerView.layer.addBorder(edge: .bottom, color: dividerColor, thickness: dividerThickness)
        

CALayer Extension

Finally, add an extension to CALayer at the base of the file. The extension creates an addBorder function that simplifies drawing the border around any UIView. It takes in the side for the border and adds a CALayer of the specified color and thickness.

extension CALayer {
    func addBorder(edge: UIRectEdge, color: UIColor, thickness: CGFloat) {

        let border = CALayer()

        switch edge {
        case .top:
            border.frame = CGRect(x: 0, y: 0, width: frame.width, height: thickness)
        case .bottom:
            border.frame = CGRect(x: 0, y: frame.height - thickness, width: frame.width, height: thickness)
        case .left:
            border.frame = CGRect(x: 0, y: 0, width: thickness, height: frame.height)
        case .right:
            border.frame = CGRect(x: frame.width - thickness, y: 0, width: thickness, height: frame.height)
        default:
            break
        }

        border.backgroundColor = color.cgColor;

        addSublayer(border)
        }
}

Credit to the StackOverflow user that created it is in the comments of the final project.

UITableViewDelegate, UITableViewDataSource, and Selector Cell

The standard UITableView delegate functions are required for this demo, abbreviated to their action term:

  • UITableViewDataSource

    • didSelectRowAt
    • heightForRowAt
    • cellForRowAt
  • UITableViewDelegate

    • numberOfRowsInSection

These methods trigger actions on user interaction, set the size of the SelectorCell, generate and repopulate a table with a new or reused cell, and tell the tableView how many total rows to render.

Refer to the code below for implementations of the methods:

extension SelectorTextField: UITableViewDelegate, UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        
        if status == .closed{
            return 0
        }else{
            return tableViewData.count
        }
        
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "selectorcell") as? SelectorCell ?? SelectorCell()
        
        cell.textLabel?.text = self.tableViewData[indexPath.row]
        cell.backgroundColor = UIColor.init(hexString: "ebeced")
        cell.selectionStyle = UITableViewCell.SelectionStyle.none
    
        return cell
    }
    
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return cellHeight
    }
    
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        
        self.textField.text = tableViewData[indexPath.row]
        triggerTable()
        
    }
    
    
}

The first customization is in the didSelectRowAt method. When a user selects a row the SelectorTextField text value is set to the value of the row that was touched. This interaction also calls the triggerTable method that toggles the animation based on the current Status value of .closed or .opened.

If the user touches a row in the table, the tableView must already be animated out and displayed so triggerTable will call the animation to close it. That method is detailed in a later section.

Another adjustment is in the numberOfRowsInSection method where a universal Status value of closed will return 0 rows for display. This is key for the animation to operate appropriately. The tableView should disappear on close. Otherwise, it will look like the "drawer" opens and closes while the tableView rows are already rendered and don't move at all.

In the triggerTable function the status is toggled between open and closed and then a call made to tableView.reloadData(). This ensures the rows will either be populated and display or not display at all, depending on the status.

The SelectorCell class is not modified at all from the autogenerated version. It is included as part of the demo project to allow addition of images, text, etc. to each row. If a more customized experience is required then take advantage of this class.

Animation

With all components initiated, positioned, and tableView delegates overridden, a final key function needs implementation: triggerTable().

The animation modifies the tableHeightConstraint using UIView.animate view animation methods. The animation toggles the size from 0 to the correct height of the tableView with all the rows exposed.

Add an extension to the class to segregate this critical method. Refer to the class below:

extension SelectorTextField{
    
    @objc private func triggerTable(){
        
        if status == .closed{
            status = .opened
            
            UIView.animate(withDuration: 0.7, delay:0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 1, options: .curveEaseInOut, animations:{
                self.tableHeightConstraint.constant +=  self.cellHeight * CGFloat(self.tableViewData.count)
                self.dropDownArrow.transform = CGAffineTransform(rotationAngle: .pi)
                self.layoutIfNeeded()
                
                DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(300), execute: {
                    self.tableView.separatorColor = UIColor.darkGray
                    self.tableView.reloadData()
                })
            }){_ in
                //Remove DispatchQueue above and uncomment this when changing aniumation
                //to not use springVelocity and Damping
                //self.tableView.separatorColor = UIColor.darkGray
                //self.tableView.reloadData()
            }
            
            
        }else{
            
            status = .closed
            self.tableView.reloadData()
            UIView.animate(withDuration: 0.7, delay:0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 1, options: .curveEaseInOut, animations:{
                
                self.tableHeightConstraint.constant -=  self.cellHeight * CGFloat(self.tableViewData.count)
                self.dropDownArrow.transform = CGAffineTransform(rotationAngle: .pi - 3.14159)
                self.tableView.separatorColor = UIColor.clear
                self.layoutIfNeeded()
                
            })
        }
        
    }
}

The appropriate animation to open or close the drawer is called based on the Status enum. A CGAffineTransform is used to rotate the dropDownArrow 180 degress on open and back the same direction it came when closed.

Set the rotationAngle to .pi - 3.1459 which enforces the rotation to be counterclockwise.

Why Call reloadData on tableView During the Animation?

The tableView function reloadData() is called after the tableView has animated out in full. This method populates the rows with data and displays them.

The closing portion of the animation sets the status to .closed and immediately calls reloadData(). This makes the table disappear before the animation starts. Why does that work? The numberOfRowsInSection DataSource delegate method returns 0 rows when the status is closed.(refer to the previous section).

Lastly, a call is made to DispatchQueue.main.asyncAfter is defined to trigger after the animation is complete.

 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(300), execute: {
    self.tableView.separatorColor = UIColor.darkGray
    self.tableView.reloadData()
})

The main thread calls this method 300 milliseconds into the 700ms open animation for smoothest animation results. It's recommended to toy with these numbers to get the best results.

ViewController Code

The final bit of code required to get everything up and running is the UIViewController where an instance of the SelectorTextField is created programmatically.

override func viewDidLoad() {

    super.viewDidLoad()

    selectorTextField = SelectorTextField(frame:  CGRect(x: 0, y: 200, width: view.bounds.width, height: 40))

    view.addSubview(selectorTextField)

    }

The only consideration is to ensure the height aligns with the cellHeight in the SelectorTextField to guarantee there's no misalignment of components. If a bigger SelectorTextField is required, make the cellHeight the same size as the height used for the frame in the UIViewController.

Final Thoughts

The SelectorTextField coding is complete. Go ahead and run the project to see the final product. Summarizing the planning that went into this control, the steps taken include:

  • Deciding which controls could be combined to work together for the target control
  • Pulling those controls into a unified class
  • Instantiating each one and implementing any required delegate functions
  • Positioning them using constraints.
  • Implementing animations if required.

There are some tweaks that need to be done to make this project ready for production. For example, double clicking on the UITextField puts it into edit/select mode. A future addition would be to intercept or switch off the standard UITextField touch interactions to only allow the tapGestureRecognizer to function.

Check back frequently for further additions to this tutorial series.