Home > database >  How to add a slider and labels to a container view programatically
How to add a slider and labels to a container view programatically

Time:10-07

I recently started with iOS development, and I'm currently working on an existing iOS Swift app with the intention of adding additional functionality. The current view contains a custom header and footer view, and the idea is for me to add the new slider with discrete steps in between, which worked. However, now I would also like to add labels to describe the discrete UISlider, for example having "Min" and "Max" to the left and right respectively, as well as the value of current value of the slider:

Slider idea

To achieve this, I was thinking to define a UITableView and a custom cell where I would insert the slider, while the labels could be defined in a row above or below the slider row. In my recent attempt I tried to define the table view and simply add the same slider element to a row, but I'm unsure how to proceed.

In addition, there is no Storyboard, everything has to be done programatically. Here is the sample code for my current version:

Slider and slider view definition:

    private var sliderView = UIView()
    private var discreteSlider = UISlider()
    private let step: Float = 1 // for UISlider to snap in steps

Table view definition:

    // temporary table view rows. For testing the table view
    private let myArray: NSArray = ["firstRow", "secondRow"]

    private lazy var tableView: UITableView = {

        let displayWidth: CGFloat = self.view.frame.width
        let displayHeight: CGFloat = self.view.frame.height / 3
        let yPos = headerHeight

        myTableView = UITableView(frame: CGRect(x: 0, y: yPos, width: displayWidth, height: displayHeight))

        myTableView.backgroundColor = .clear

        myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "MyCell")
        myTableView.dataSource = self
        myTableView.delegate = self

        return myTableView
    }()

Loading the views:

    private func setUpView() {
        
        // define slider
        discreteSlider = UISlider(frame:CGRect(x: 0, y: 0, width: 250, height: 20))

        // define slider properties
        discreteSlider.center = self.view.center
        discreteSlider.minimumValue = 1
        discreteSlider.maximumValue = 5
        discreteSlider.isContinuous = true
        discreteSlider.tintColor = UIColor.purple

        // add behavior
        discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
        
        sliderView.addSubviews(discreteSlider) // add the slider to its view

        UIView.animate(withDuration: 0.8) {
            self.discreteSlider.setValue(2.0, animated: true)
        }
        
        ////// 
        // Add the slider, labels to table rows here

        // Add the table view to the main view
        view.addSubviews(headerView, tableView, footerView)
        //////
        
        //current version without the table
        //view.addSubviews(headerView, sliderView, footerView)

        headerView.title = "View Title". // header configuration
    }

Class extension for the table view:

extension MyViewController: UITableViewDelegate, UITableViewDataSource {
    

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("Num: \(indexPath.row)")
        print("Value: \(myArray[indexPath.row])")
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return myArray.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell", for: indexPath as IndexPath)
        cell.textLabel!.text = "\(myArray[indexPath.row])"
        return cell
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableView.automaticDimension
    }
    
}

Furthermore, if there is a better solution that the UITableView approach, I would be willing to try. I also started to look over UICollectionView. Thanks!

CodePudding user response:

While you could put these elements in different rows / cells of a table view, that's not what table views are designed for and there is a much better approach.

Create a UIView subclass and use auto-layout constraints to position the elements:

enter image description here

We use a horizontal UIStackView for the "step" labels... Distribution is set to .equalSpacing and we constrain the labels to all be equal widths.

We constrain the slider above the stack view, constraining its Leading and Trailing to the centerX of the first and last step labels (with /- offsets for the width of the thumb).

We constrain the centerX of the Min and Max labels to the centerX of the first and last step labels.

Here is an example:

class MySliderView: UIView {
    
    private var discreteSlider = UISlider()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        let minVal: Int = 1
        let maxVal: Int = 5
        
        // slider properties
        discreteSlider.minimumValue = Float(minVal)
        discreteSlider.maximumValue = Float(maxVal)
        discreteSlider.isContinuous = true
        discreteSlider.tintColor = UIColor.purple

        let stepStack = UIStackView()
        stepStack.distribution = .equalSpacing
        
        for i in minVal...maxVal {
            let v = UILabel()
            v.text = "\(i)"
            v.textAlignment = .center
            v.textColor = .systemRed
            stepStack.addArrangedSubview(v)
        }
        
        // references to first and last step label
        guard let firstLabel = stepStack.arrangedSubviews.first,
              let lastLabel = stepStack.arrangedSubviews.last
        else {
            // this will never happen, but we want to
            //  properly unwrap the labels
            return
        }
        
        // make all step labels the same width
        stepStack.arrangedSubviews.dropFirst().forEach { v in
            v.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
        }
        
        let minLabel = UILabel()
        minLabel.text = "Min"
        minLabel.textAlignment = .center
        minLabel.textColor = .systemRed

        let maxLabel = UILabel()
        maxLabel.text = "Max"
        maxLabel.textAlignment = .center
        maxLabel.textColor = .systemRed
        
        // add the labels and the slider to self
        [minLabel, maxLabel, discreteSlider, stepStack].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            addSubview(v)
        }

        // now we setup the layout

        NSLayoutConstraint.activate([
            
            // start with the step labels stackView
            
            // we'll give it 40-pts leading and trailing "padding"
            stepStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40.0),
            stepStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40.0),
            
            // and 20-pts from the bottom
            stepStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),

            // now constrain the slider leading and trailing to the
            //  horizontal center of first and last step labels
            //  accounting for width of thumb (assuming a default UISlider)
            discreteSlider.leadingAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: -14.0),
            discreteSlider.trailingAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 14.0),
            
            // and 20-pts above the steps stackView
            discreteSlider.bottomAnchor.constraint(equalTo: stepStack.topAnchor, constant: -20.0),
            
            // constrain Min and Max labels centered to first and last step labels
            minLabel.centerXAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: 0.0),
            maxLabel.centerXAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 0.0),
            
            // and 20-pts above the steps slider
            minLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
            maxLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),

            // and 20-pts top "padding"
            minLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
        ])

        // add behavior
        discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
        discreteSlider.addTarget(self, action: #selector(self.sliderThumbReleased(_:)), for: .touchUpInside)

    }
    
    // so we can set the slider value from the controller
    public func setSliderValue(_ val: Float) -> Void {
        discreteSlider.setValue(val, animated: true)
    }
    
    @objc func sliderValueDidChange(_ sender: UISlider) -> Void {
        print("Slider dragging value:", sender.value)
    }
    @objc func sliderThumbReleased(_ sender: UISlider) -> Void {
        // "snap" to discreet step position
        sender.setValue(Float(lroundf(sender.value)), animated: true)
        print("Slider dragging end value:", sender.value)
    }
    
}

and it ends up looking like this:

enter image description here

Note that the target action for the slider value change is contained inside our custom class.

So, we need to provide functionality so our class can inform the controller when the slider value has changed.

The best way to do that is with closures...

We'll define the closures at the top of our MySliderView class:

class MySliderView: UIView {
    
    // this closure will be used to inform the controller that
    //  the slider value changed
    var sliderDraggingClosure: ((Float)->())?
    var sliderReleasedClosure: ((Float)->())?
    

then in our slider action funcs, we can use that closure to "call back" to the controller:

@objc func sliderValueDidChange(_ sender: UISlider) -> Void {
    // tell the controller
    sliderDraggingClosure?(sender.value)
}
@objc func sliderThumbReleased(_ sender: UISlider) -> Void {
    // "snap" to discreet step position
    sender.setValue(Float(lroundf(sender.value)), animated: true)
    // tell the controller
    sliderReleasedClosure?(sender.value)
}

and then in our view controller's viewDidLoad() func, we setup the closures:

    // set the slider closures
    mySliderView.sliderDraggingClosure = { [weak self] val in
        print("Slider dragging value:", val)
        // make sure self is still valid
        guard let self = self else {
            return
        }
        // do something because the slider changed
        // self.someFunc()
    }
    mySliderView.sliderReleasedClosure = { [weak self] val in
        print("Slider dragging end value:", val)
        // make sure self is still valid
        guard let self = self else {
            return
        }
        // do something because the slider changed
        // self.someFunc()
    }

Here's the complete modified class (Edited to include Tap behavior):

class MySliderView: UIView {
    
    // this closure will be used to inform the controller that
    //  the slider value changed
    var sliderDraggingClosure: ((Float)->())?
    var sliderReleasedClosure: ((Float)->())?
    
    private var discreteSlider = UISlider()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        let minVal: Int = 1
        let maxVal: Int = 5
        
        // slider properties
        discreteSlider.minimumValue = Float(minVal)
        discreteSlider.maximumValue = Float(maxVal)
        discreteSlider.isContinuous = true
        discreteSlider.tintColor = UIColor.purple
        
        let stepStack = UIStackView()
        stepStack.distribution = .equalSpacing
        
        for i in minVal...maxVal {
            let v = UILabel()
            v.text = "\(i)"
            v.textAlignment = .center
            v.textColor = .systemRed
            stepStack.addArrangedSubview(v)
        }
        
        // references to first and last step label
        guard let firstLabel = stepStack.arrangedSubviews.first,
              let lastLabel = stepStack.arrangedSubviews.last
        else {
            // this will never happen, but we want to
            //  properly unwrap the labels
            return
        }
        
        // make all step labels the same width
        stepStack.arrangedSubviews.dropFirst().forEach { v in
            v.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
        }
        
        let minLabel = UILabel()
        minLabel.text = "Min"
        minLabel.textAlignment = .center
        minLabel.textColor = .systemRed
        
        let maxLabel = UILabel()
        maxLabel.text = "Max"
        maxLabel.textAlignment = .center
        maxLabel.textColor = .systemRed
        
        // add the labels and the slider to self
        [minLabel, maxLabel, discreteSlider, stepStack].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            addSubview(v)
        }
        
        // now we setup the layout
        
        NSLayoutConstraint.activate([
            
            // start with the step labels stackView
            
            // we'll give it 40-pts leading and trailing "padding"
            stepStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40.0),
            stepStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40.0),
            
            // and 20-pts from the bottom
            stepStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),
            
            // now constrain the slider leading and trailing to the
            //  horizontal center of first and last step labels
            //  accounting for width of thumb (assuming a default UISlider)
            discreteSlider.leadingAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: -14.0),
            discreteSlider.trailingAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 14.0),
            
            // and 20-pts above the steps stackView
            discreteSlider.bottomAnchor.constraint(equalTo: stepStack.topAnchor, constant: -20.0),
            
            // constrain Min and Max labels centered to first and last step labels
            minLabel.centerXAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: 0.0),
            maxLabel.centerXAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 0.0),
            
            // and 20-pts above the steps slider
            minLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
            maxLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
            
            // and 20-pts top "padding"
            minLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
        ])
        
        // add behavior
        discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
        discreteSlider.addTarget(self, action: #selector(self.sliderThumbReleased(_:)), for: .touchUpInside)
    
        // add tap gesture so user can either
        //  Drag the Thumb or
        //  Tap the slider bar
        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(sliderTapped))
        discreteSlider.addGestureRecognizer(tapGestureRecognizer)
    }
    
    // so we can set the slider value from the controller
    public func setSliderValue(_ val: Float) -> Void {
        discreteSlider.setValue(val, animated: true)
    }
    
    @objc func sliderValueDidChange(_ sender: UISlider) -> Void {
        // tell the controller
        sliderDraggingClosure?(sender.value)
    }
    @objc func sliderThumbReleased(_ sender: UISlider) -> Void {
        // "snap" to discreet step position
        sender.setValue(Float(sender.value.rounded()), animated: true)
        // tell the controller
        sliderReleasedClosure?(sender.value)
    }
    
    @objc func sliderTapped(_ gesture: UITapGestureRecognizer) {
        guard gesture.state == .ended else { return }
        guard let slider = gesture.view as? UISlider else { return }

        // get tapped point
        let pt: CGPoint = gesture.location(in: slider)
        let widthOfSlider: CGFloat = slider.bounds.size.width

        // calculate tapped point as percentage of width
        let pct = pt.x / widthOfSlider
        
        // convert to min/max value range
        let pctRange = pct * CGFloat(slider.maximumValue - slider.minimumValue)   CGFloat(slider.minimumValue)

        // "snap" to discreet step position
        let newValue = Float(pctRange.rounded())
        slider.setValue(newValue, animated: true)

        // tell the controller
        sliderReleasedClosure?(newValue)
    }
}

along with an example view controller:

class SliderTestViewController: UIViewController {
    
    let mySliderView = MySliderView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        mySliderView.translatesAutoresizingMaskIntoConstraints = false
        
        mySliderView.backgroundColor = .darkGray
        
        view.addSubview(mySliderView)
        
        // respect safe area
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // let's put our custom slider view
            //  40-pts from the top with
            //  8-pts leading and trailing
            mySliderView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
            mySliderView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
            mySliderView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
            
            // we don't need Bottom or Height constraints, because our custom view's content
            //  will determine its Height
            
        ])
        
        // set the slider closures
        mySliderView.sliderDraggingClosure = { [weak self] val in
            print("Slider dragging value:", val)
            // make sure self is still valid
            guard let self = self else {
                return
            }
            // do something because the slider changed
            // self.someFunc()
        }
        mySliderView.sliderReleasedClosure = { [weak self] val in
            print("Slider dragging end value:", val)
            // make sure self is still valid
            guard let self = self else {
                return
            }
            // do something because the slider changed
            // self.someFunc()
        }
        
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // start the slider at 4
        UIView.animate(withDuration: 0.8) {
            self.mySliderView.setSliderValue(4)
        }
        
    }
    
}
  • Related