swiftuitableviewuikitawakefromnib

Custom UITableViewCell, UISlider subview unexpected sizing behavior


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


Solution

  • 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.