Custom UITableViewCell, UISlider subview unexpected sizing behavior

64 views Asked by At

I have a UISlider in a custom UITableViewCell. When I look at the size of the slider in awakeFromNib the .frame property shows the size of the slider as it was set in the storyboard, not the final size as it is drawn when the view appears.

I had thought all of that set up was done in awakeFromNib but the size of the slider seems to change between awakeFromNib and its final appearance.

I found a similar question from 2015 that had an answer posted but was not actually resolved.

UITableViewCell: understanding life cycle

I also found a similar question from 2016, but that one doesn't seem to apply to my situation.

Swift UITableViewCell Subview Layout Updating Delayed

I have added a screen capture of my constraints as set in the storyboard.

UISlider constraints

1

There are 1 answers

3
DonMag On BEST ANSWER

We don't know the size of the cell (and its UI components) until layoutSubviews()

So, assuming you are setting the arrow positions as percentages, implement layoutSubviews() in your cell class along these lines:

override func layoutSubviews() {
    super.layoutSubviews()
    
    // the thumb "circle" extends to the bounds / frame of the slider
    // so, this is how we get the
    //  thumb center-to-center
    //  when value is 0 or 1.0
    let trackRect = theSlider.trackRect(forBounds: theSlider.bounds)
    let thumbRect = theSlider.thumbRect(forBounds: theSlider.bounds, trackRect: trackRect, value: 0.0)
    let rangeWidth = theSlider.bounds.width - thumbRect.width
    
    // Zero will be 1/2 of the width of the thumbRect
    //  minus 2 (because the thumb image is slightly offset from the thumb rect)
    let xOffset = (thumbRect.width * 0.5) - 2.0
    
    // create the arrow constraints if needed
    if startConstraint == nil {
        startConstraint = startArrow.centerXAnchor.constraint(equalTo: theSlider.leadingAnchor)
        startConstraint.isActive = true
    }
    if endConstraint == nil {
        endConstraint = endArrow.centerXAnchor.constraint(equalTo: theSlider.leadingAnchor)
        endConstraint.isActive = true
    }
    
    // set arrow constraint constants
    startConstraint.constant = rangeWidth * startTime + xOffset
    endConstraint.constant = rangeWidth * endTime + xOffset
}

I'm assuming all of your rows will have the same "time range" for the slider, so we can get something like this (I set the thumb tint to translucent and the arrow y-positions so we can see the alignment):

enter image description here

For a complete example (to produce that output), use this Storyboard https://pastebin.com/nUZFMtGN (had to move it since this answer became too long) - and this code:

class SliderCell: UITableViewCell {
    // startTime and endTime are in Percentages
    public var startTime: Double = 0.0 { didSet { setNeedsLayout() } }
    public var endTime: Double = 0.0 { didSet { setNeedsLayout() } }
    
    @IBOutlet var startArrow: UIImageView!
    @IBOutlet var endArrow: UIImageView!

    @IBOutlet var dateLabel: UILabel!
    @IBOutlet var startEndLabel: UILabel!
    
    @IBOutlet var minLabel: UILabel!
    @IBOutlet var maxLabel: UILabel!
    
    @IBOutlet var theSlider: UISlider!
    
    private var startConstraint: NSLayoutConstraint!
    private var endConstraint: NSLayoutConstraint!
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // the thumb "circle" extends to the bounds / frame of the slider
        // so, this is how we get the
        //  thumb center-to-center
        //  when value is 0 or 1.0
        let trackRect = theSlider.trackRect(forBounds: theSlider.bounds)
        let thumbRect = theSlider.thumbRect(forBounds: theSlider.bounds, trackRect: trackRect, value: 0.0)
        let rangeWidth = theSlider.bounds.width - thumbRect.width
        
        // Zero will be 1/2 of the width of the thumbRect
        //  minus 2 (because the thumb image is slightly offset from the thumb rect)
        let xOffset = (thumbRect.width * 0.5) - 2.0
        
        // create the arrow constraints if needed
        if startConstraint == nil {
            startConstraint = startArrow.centerXAnchor.constraint(equalTo: theSlider.leadingAnchor)
            startConstraint.isActive = true
        }
        if endConstraint == nil {
            endConstraint = endArrow.centerXAnchor.constraint(equalTo: theSlider.leadingAnchor)
            endConstraint.isActive = true
        }
        
        // set arrow constraint constants
        startConstraint.constant = rangeWidth * startTime + xOffset
        endConstraint.constant = rangeWidth * endTime + xOffset
    }
}

struct MyTimeInfo {
    var startTime: Date = Date()
    var endTime: Date = Date()
}

class SliderTableVC: UITableViewController {
    
    var myData: [MyTimeInfo] = []
    
    var minTime: Double = 0
    var maxTime: Double = 24

    var minTimeStr: String = ""
    var maxTimeStr: String = ""
    
    var timeRange: Double = 24
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // let's generate some sample data
        let starts: [Double] = [
            8, 7, 11, 10.5, 8.25, 9,
        ]
        let ends: [Double] = [
            20, 23, 19, 16.5, 21.75, 21,
        ]
        let y = 2023
        let m = 11
        var d = 1
        for (s, e) in zip(starts, ends) {
            var dateComponents = DateComponents()
            dateComponents.year = y
            dateComponents.month = m
            dateComponents.day = d
            dateComponents.hour = Int(s)
            dateComponents.minute = Int((s - Double(Int(s))) * 60.0)
            let sDate = Calendar.current.date(from: dateComponents)!
            dateComponents.hour = Int(e)
            dateComponents.minute = Int((e - Double(Int(e))) * 60.0)
            let eDate = Calendar.current.date(from: dateComponents)!
            myData.append(MyTimeInfo(startTime: sDate, endTime: eDate))
            d += 1
        }
        
        minTime = starts.min() ?? 0
        maxTime = ends.max() ?? 24
        timeRange = maxTime - minTime
        
        minTimeStr = timeStringFromDouble(minTime)
        maxTimeStr = timeStringFromDouble(maxTime)
        
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return myData.count
    }
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let c = tableView.dequeueReusableCell(withIdentifier: "sliderCell", for: indexPath) as! SliderCell
        
        let calendar = Calendar.current

        var h = calendar.component(.hour, from: myData[indexPath.row].startTime)
        var m = calendar.component(.minute, from: myData[indexPath.row].startTime)

        let s: Double = Double(h) + Double(m) / 60.0

        h = calendar.component(.hour, from: myData[indexPath.row].endTime)
        m = calendar.component(.minute, from: myData[indexPath.row].endTime)
        
        let e: Double = Double(h) + Double(m) / 60.0
        
        let sPct: Double = (s - minTime) / timeRange
        let ePct: Double = (e - minTime) / timeRange
        
        let df = DateFormatter()
        df.timeStyle = .short

        let sStr = df.string(from: myData[indexPath.row].startTime)
        let eStr = df.string(from: myData[indexPath.row].endTime)

        df.dateStyle = .short
        df.timeStyle = .none
        
        c.dateLabel.text = df.string(from: myData[indexPath.row].startTime)
        
        c.startTime = max(sPct, 0.0)
        c.endTime = min(ePct, 1.0)
        
        c.startEndLabel.text = sStr + " - " + eStr
        
        c.minLabel.text = minTimeStr
        c.maxLabel.text = maxTimeStr
        
        return c
        
    }
    func timeStringFromDouble(_ t: Double) -> String {
        
        let df = DateFormatter()
        df.timeStyle = .short
        
        var dateComponents = DateComponents()
        dateComponents.hour = Int(t)
        dateComponents.minute = Int((t - Double(Int(t))) * 60.0)
        
        var date = Calendar.current.date(from: dateComponents)!
        return df.string(from: date)

    }
}

Edit

If we want, we can get rid of the position calculations in layoutSubviews() altogether...

Let's start with a custom slider thumb image, which we can generate at run-time using an SF Symbol - the background will be clear:

enter image description here enter image description here

If we use that with .setThumbImage(arrowThumb, for: []), it will look like this (I've given it a translucent background for clarity):

enter image description here

Now we could, for example, set the slider's:

.minimumValue = 7.0   // (hours - 7:00 am)
.maximumValue = 23.0  // (hours - 11:00 pm)

and then set the value to the time.

So, we can use one for the "start time" and overlay another one for the "end time":

enter image description here

If we then set:

.setMinimumTrackImage(UIImage(), for: [])
.setMaximumTrackImage(UIImage(), for: [])

on both sliders, we get this:

enter image description here

We'll set .isUserInteractionEnabled = false for both of those sliders, and overlay an interactive slider on top:

enter image description here

Debug View Hierarchy:

enter image description here

When we remove the translucent background:

enter image description here

At this point, we no longer need to do anything in layoutSubviews() ... we just set the .value of the "startMarkerSlider" and the "endMarkerSlider" and the arrow-markers will be automatically positioned.

enter image description here

Here's example code for that approach - all code, no @IBOutlet or @IBAction connections...

// convenience extension to manage Date Times as fractions
// for example
//  convert from to 10:15 to 10.25
// and
//  convert from 10.25 to 10:15
extension Date {
    var fractionalTime: Double {
        get {
            let calendar = Calendar.current
            let h = calendar.component(.hour, from: self)
            let m = calendar.component(.minute, from: self)
            return Double(h) + Double(m) / 60.0
        }
        set {
            let calendar = Calendar.current
            var components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: self)
            components.hour = Int(newValue)
            components.minute = Int(newValue * 60.0) % 60
            self = calendar.date(from: components)!
        }
    }
}

Table View Cell class

class AnotherSliderCell: UITableViewCell {
    
    public var sliderClosure: ((UITableViewCell, Double) -> ())?
    
    private var minTime: Date = Date() { didSet {
        startMarkerSlider.minimumValue = Float(minTime.fractionalTime)
        endMarkerSlider.minimumValue = startMarkerSlider.minimumValue
        theSlider.minimumValue = startMarkerSlider.minimumValue
    }}
    private var maxTime: Date = Date() { didSet {
        startMarkerSlider.maximumValue = Float(maxTime.fractionalTime)
        endMarkerSlider.maximumValue = startMarkerSlider.maximumValue
        theSlider.maximumValue = startMarkerSlider.maximumValue
    }}
    private var startTime: Date = Date() { didSet {
        startMarkerSlider.setValue(Float(startTime.fractionalTime), animated: false)
    }}
    private var endTime: Date = Date() { didSet {
        endMarkerSlider.setValue(Float(endTime.fractionalTime), animated: false)
    }}
    private var selectedTime: Date = Date() { didSet {
        theSlider.setValue(Float(selectedTime.fractionalTime), animated: false)
    }}

    private let theSlider = UISlider()
    private let startMarkerSlider = UISlider()
    private let endMarkerSlider = UISlider()
    
    private let infoLabel = UILabel()
    private let minLabel = UILabel()
    private let maxLabel = UILabel()
    private let selLabel = UILabel()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        [endMarkerSlider, startMarkerSlider, theSlider, infoLabel, minLabel, maxLabel, selLabel].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(v)
        }
        let g = contentView.layoutMarginsGuide
        NSLayoutConstraint.activate([
            
            infoLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),

            theSlider.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 6.0),
            theSlider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            theSlider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            
            minLabel.topAnchor.constraint(equalTo: theSlider.bottomAnchor, constant: 8.0),
            minLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            minLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),

            maxLabel.topAnchor.constraint(equalTo: minLabel.topAnchor, constant: 0.0),
            maxLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            
            maxLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            
            selLabel.topAnchor.constraint(equalTo: minLabel.topAnchor, constant: 0.0),
            selLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
        ])
        
        // constrain sliders to overlay each other
        [endMarkerSlider, startMarkerSlider].forEach { v in
            NSLayoutConstraint.activate([
                v.topAnchor.constraint(equalTo: theSlider.topAnchor, constant: 0.0),
                v.leadingAnchor.constraint(equalTo: theSlider.leadingAnchor, constant: 0.0),
                v.trailingAnchor.constraint(equalTo: theSlider.trailingAnchor, constant: 0.0),
                v.bottomAnchor.constraint(equalTo: theSlider.bottomAnchor, constant: 0.0),
            ])
        }

        var arrowThumb: UIImage!

        // if we can get the "arrowshape.up" SF Symbol (iOS 17 or custom), use it
        //  else
        // if we can get the "arrowshape.left" SF Symbol, rotate and use it
        //  else
        // use a bezier path to draw the arrow
        if let sfArrow = UIImage(systemName: "arrowshape.up") {
            
            let newSize: CGSize = .init(width: 31.0, height: (sfArrow.size.height * 2.0) + 3.0)
            let xOff = (newSize.width - sfArrow.size.width) * 0.5
            let yOff = (newSize.height - sfArrow.size.height)
            
            arrowThumb = UIGraphicsImageRenderer(size:newSize).image { renderer in
                // during development, if we want to see the thumb image framing
                //UIColor.red.withAlphaComponent(0.25).setFill()
                //renderer.cgContext.fill(CGRect(origin: .zero, size: newSize))
                sfArrow.draw(at: .init(x: xOff, y: yOff))
            }
            
        } else if let sfArrow = UIImage(systemName: "arrowshape.left") {
            
            let sizeOfImage = sfArrow.size
            var newSize = CGRect(origin: .zero, size: sizeOfImage).applying(CGAffineTransform(rotationAngle: .pi * 0.5)).size
            
            // Trim off the extremely small float value to prevent core graphics from rounding it up
            newSize.width = floor(newSize.width)
            newSize.height = floor(newSize.height)
            
            let rotArrow = UIGraphicsImageRenderer(size:newSize).image { renderer in
                //rotate from center
                renderer.cgContext.translateBy(x: newSize.width/2, y: newSize.height/2)
                renderer.cgContext.rotate(by: .pi * 0.5)
                sfArrow.draw(at: .init(x: -newSize.height / 2, y: -newSize.width / 2))
            }
            
            newSize = .init(width: 31.0, height: (rotArrow.size.height * 2.0) + 3.0)
            var xOff: CGFloat = (newSize.width - rotArrow.size.width) * 0.5
            var yOff: CGFloat = newSize.height - rotArrow.size.height
            
            arrowThumb = UIGraphicsImageRenderer(size:newSize).image { renderer in
                // during development, if we want to see the thumb image framing
                //UIColor.red.withAlphaComponent(0.25).setFill()
                //renderer.cgContext.fill(CGRect(origin: .zero, size: newSize))
                rotArrow.draw(at: .init(x: xOff, y: yOff))
            }
            
        } else {

            let vr: CGRect = .init(x: 0.0, y: 0.0, width: 31.0, height: 40.0)
            let r: CGRect = .init(x: 6.5, y: 23.0, width: 18.0, height: 16.0)
            
            var pt: CGPoint = .zero
            let pth = UIBezierPath()
            
            pt.x = r.midX - 3.0
            pt.y = r.maxY
            pth.move(to: pt)
            pt.y = r.maxY - 8.0
            pth.addLine(to: pt)
            pt.x = r.minX
            pth.addLine(to: pt)
            pt.x = r.midX
            pt.y = r.minY
            pth.addLine(to: pt)
            pt.x = r.maxX
            pt.y = r.maxY - 8.0
            pth.addLine(to: pt)
            pt.x = r.midX + 3.0
            pth.addLine(to: pt)
            pt.y = r.maxY
            pth.addLine(to: pt)
            pth.close()
            
            arrowThumb = UIGraphicsImageRenderer(size: vr.size).image { ctx in
                
                ctx.cgContext.setStrokeColor(UIColor.red.cgColor)
                
                ctx.cgContext.setLineWidth(1)
                ctx.cgContext.setLineJoin(.round)
                
                ctx.cgContext.addPath(pth.cgPath)
                ctx.cgContext.drawPath(using: .stroke)
                
            }
            
        }
        
        [endMarkerSlider, startMarkerSlider].forEach { v in
            v.setThumbImage(arrowThumb, for: [])
            v.setMinimumTrackImage(UIImage(), for: [])
            v.setMaximumTrackImage(UIImage(), for: [])
            v.isUserInteractionEnabled = false
        }

        infoLabel.font = .systemFont(ofSize: 16.0, weight: .regular)
        infoLabel.textAlignment = .center
        infoLabel.numberOfLines = 0
        
        minLabel.font = .systemFont(ofSize: 12.0, weight: .light)
        maxLabel.font = minLabel.font

        selLabel.font = minLabel.font
        selLabel.textColor = .systemRed

        theSlider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
        
        theSlider.thumbTintColor = .green.withAlphaComponent(0.25)
    }
    
    @objc func sliderChanged(_ sender: UISlider) {
        let df = DateFormatter()
        
        df.dateStyle = .none
        df.timeStyle = .short
        
        var dt = Date()
        dt.fractionalTime = Double(sender.value)
        selLabel.text = "Thumb Time: " + df.string(from: dt)

        sliderClosure?(self, Double(sender.value))
    }
    
    public func fillData(minTime: Date, maxTime: Date, mti: MyTimeInfo) {
        let df = DateFormatter()
        
        df.dateStyle = .full
        df.timeStyle = .none
        
        let part1: String = df.string(from: mti.startTime)

        df.dateStyle = .none
        df.timeStyle = .short
        
        let startStr: String = df.string(from: mti.startTime)
        let endStr: String   = df.string(from: mti.endTime)
        let selStr: String   = df.string(from: mti.selectedTime)

        let minStr: String = df.string(from: minTime)
        let maxStr: String = df.string(from: maxTime)

        infoLabel.text = part1 + "\n" + "Marker Times" + "\n" + startStr + " - " + endStr
        minLabel.text = minStr
        maxLabel.text = maxStr
        selLabel.text = "Thumb Time: " + selStr
        
        self.minTime = minTime
        self.maxTime = maxTime
        self.startTime = mti.startTime
        self.endTime = mti.endTime
        self.selectedTime = mti.selectedTime
    }
    
    // we don't need layoutSubviews() anymore
    //override func layoutSubviews() {
    //  super.layoutSubviews()
    //}
    
}

Example controller class

class AnotherSliderTableVC: UITableViewController {
    
    var myData: [MyTimeInfo] = []
    
    var minTime: Date = Date()
    var maxTime: Date = Date()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        // let's generate some sample data
        let samples: [[String]] = [
            ["11/2/2023 9:00 AM", "11/2/2023 9:00 PM"],
            ["11/2/2023 9:00 AM", "11/2/2023 5:00 PM"],
            ["11/3/2023 10:00 AM", "11/3/2023 5:00 PM"],
            ["11/4/2023 10:20 AM", "11/4/2023 2:45 PM"],
            ["11/5/2023 9:15 AM", "11/5/2023 9:30 PM"],
            ["11/6/2023 11:00 AM", "11/6/2023 6:00 PM"],
            ["11/7/2023 11:45 AM", "11/7/2023 7:30 PM"],
            ["11/8/2023 10:45 AM", "11/8/2023 4:00 PM"],
            ["11/9/2023 8:35 AM", "11/9/2023 9:00 PM"],
        ]
        
        let df = DateFormatter()
        df.dateFormat = "MM/dd/yyyy h:mm a"
        
        samples.forEach { ss in
            if let st = df.date(from: ss[0]),
               let et = df.date(from: ss[1]) {
                var selt = st
                // init with 12:00 as selectedTime for all samples
                selt.fractionalTime = 12.0
                let mt = MyTimeInfo(startTime: st, endTime: et, selectedTime: selt)
                myData.append(mt)
            }
        }
        
        // let's use these min/max times for the sliders
        //  the Date will be ignored ... only the Time will be used
        var sTmp = "11/2/2023 7:00 AM"
        if let d = df.date(from: sTmp) {
            minTime = d
        }
        sTmp = "11/2/2023 11:00 PM"
        if let d = df.date(from: sTmp) {
            maxTime = d
        }
        
        tableView.register(AnotherSliderCell.self, forCellReuseIdentifier: "ac")
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return myData.count
    }
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cc = tableView.dequeueReusableCell(withIdentifier: "ac", for: indexPath) as! AnotherSliderCell
        cc.fillData(minTime: minTime, maxTime: maxTime, mti: myData[indexPath.row])
        cc.sliderClosure = { [weak self] theCell, theValue in
            guard let self = self,
                  let idx = tableView.indexPath(for: theCell)
            else { return }
            self.myData[idx.row].selectedTime.fractionalTime = theValue
        }
        return cc
    }
    func timeStringFromDouble(_ t: Double) -> String {
        
        let df = DateFormatter()
        df.timeStyle = .short
        
        var dateComponents = DateComponents()
        dateComponents.hour = Int(t)
        dateComponents.minute = Int((t - Double(Int(t))) * 60.0)
        
        let date = Calendar.current.date(from: dateComponents)!
        return df.string(from: date)
        
    }
}

Note that I also changed the approach to using the data, so we're dealing directly with Date objects.