【问题标题】:How to add a slider and labels to a container view programatically如何以编程方式将滑块和标签添加到容器视图
【发布时间】:2021-11-25 14:02:38
【问题描述】:

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

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

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

滑块和滑块视图定义:

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

表格视图定义:

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

表格视图的类扩展:

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。谢谢!

【问题讨论】:

  • 查看 UIStackView ,它可以水平或垂直范围内的视图。您还可以在堆栈视图中包含堆栈视图。

标签: ios swift uitableview uikit uislider


【解决方案1】:

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

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

我们将水平的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)
    }
    
}

它最终看起来像这样:

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

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

最好的方法是使用closures...

我们将在 MySliderView 类的顶部定义闭包:

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() func 中,我们设置了闭包:

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

如果您想让滑块“可点击区域”更大,请使用子类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 - 水平和垂直扩展点击区域以包括 整个 超级视图:

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

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

【讨论】:

  • 绝妙的答案!感谢您的详细解释,我实现了新的滑块视图并同时学到了很多东西。现在,我认为如果滑块值也可以通过单次触摸设置会很棒,这样用户就可以“滑动”或进行一次触摸选择。有没有我们可以设置的操作?
  • @JohnSzatmari - 您可以通过添加UITapGestureRecognizer 然后计算发生点击的滑块的百分比来做到这一点。我在答案中编辑 MySliderView 类,添加了该功能。
  • 成功了,谢谢。关于滑块,我注意到对于一些拇指较大的用户来说,“触摸”区域可能很小。是不是也可以在MySliderView增加呢?
  • @JohnSzatmari - 请参阅我的 编辑 2。请注意,这远远超出了您原始问题的范围。如果您还有其他问题,您应该创建一个新帖子。
猜你喜欢
  • 2011-10-05
  • 2016-09-19
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-01-04
相关资源
最近更新 更多