如何以编程方式将滑块和标签添加到容器视图

How to add a slider and labels to a container view programatically

我最近开始 iOS 开发,目前我正在开发一个现有的 iOS Swift 应用程序,目的是添加额外的功能。当前视图包含一个自定义页眉和页脚视图,我的想法是添加新的滑块,中间有不连续的步骤,这很有效。但是,现在我还想添加标签来描述离散 UISlider,例如分别在左侧和右侧添加“Min”和“Max”,以及滑块当前值的值:

为了实现这一点,我想定义一个 UITableView 和一个自定义单元格,我将在其中插入滑块,而标签可以定义在滑块行上方或下方的一行中。在我最近的尝试中,我尝试定义 table 视图并将相同的滑块元素简单地添加到一行中,但我不确定如何继续。

此外,没有故事板,一切都必须通过编程来完成。这是我当前版本的示例代码:

滑块和滑块视图定义:

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

Table 查看定义:

    // 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
    }()

正在加载视图:

    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 table 视图的扩展:

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
    }
    
}

此外,如果有比UITableView方法更好的解决方案,我也愿意尝试。我也开始翻看UICollectionView。谢谢!

虽然您 可以 将这些元素放在 table 视图的不同行/单元格中,但这不是 table 视图的设计目的,并且有一个更好的方法。

创建一个 UIView subclass 并使用自动布局约束来定位元素:

我们对“阶梯”标签使用水平 UIStackView...分布设置为 .equalSpacing 并且我们将所有标签限制为等宽。

我们将滑块约束在堆栈视图上方,将其 Leading 和 Trailing 约束到第一步和最后一步标签的 centerX(具有拇指宽度的 +/- 偏移量)。

我们将 Min 和 Max 标签的 centerX 约束为第一步和最后一步标签的 centerX。

这是一个例子:

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)
    }
    
}

最后看起来像这样:

请注意,滑块值更改的目标操作包含在我们的自定义 class 中。

因此,我们需要提供功能,以便我们的 class 可以在滑块值发生变化时通知控制器。

最好的方法是 closures...

我们将在 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)->())?
    

然后在我们的滑块操作函数中,我们可以使用该闭包“回调”控制器:

@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)
}

然后在我们的视图控制器的 viewDidLoad() 函数中,我们设置闭包:

    // 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()
    }

这是完整的修改后的 class(编辑 以包括点击行为):

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)
    }
}

连同示例视图控制器:

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)
        }
        
    }
    
}

编辑 2

如果您想让滑块的“可点击区域”变大,请使用子classed UISlider 并覆盖 point(inside, ...).

示例 1 - 将抽头区域每侧扩展 10 磅,顶部和底部扩展 15 磅:

class ExpandedTouchSlider: UISlider {
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        // expand tap area 10-pts on each side, 15-pts top and bottom
        let bounds: CGRect = self.bounds.insetBy(dx: -10.0, dy: -15.0)
        return bounds.contains(point)
    }
    
}

示例 2 - 将点击区域垂直扩展到父视图高度:

class ExpandedTouchSlider: UISlider {
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        var bounds: CGRect = self.bounds
        if let sv = superview {
            // expand tap area vertically to superview height
            let svRect = sv.bounds
            let f = self.frame
            bounds.origin.y -= f.origin.y
            bounds.size.height = svRect.height
        }
        return bounds.contains(point)
    }
    
}

示例 3 - 水平和垂直扩展点击区域以包括 整个 superview:

class ExpandedTouchSlider: UISlider {
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        var bounds: CGRect = self.bounds
        if let sv = superview {
            // expand tap area both horizontally and vertically
            //  to include entire superview
            let svRect = sv.bounds
            let f = self.frame
            bounds.origin.x -= f.origin.x
            bounds.origin.y -= f.origin.y
            bounds.size.width = svRect.width
            bounds.size.height = svRect.height
        }
        return bounds.contains(point)
    }
    
}

请注意,如果您要水平扩展点击区域(以便用户可以点击滑块的 left/right 端),您还需要确保您的百分比/值计算不会产生低于最小值或高于最大值的值。