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.
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):
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:
If we use that with .setThumbImage(arrowThumb, for: [])
, it will look like this (I've given it a translucent background for clarity):
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":
If we then set:
.setMinimumTrackImage(UIImage(), for: [])
.setMaximumTrackImage(UIImage(), for: [])
on both sliders, we get this:
We'll set .isUserInteractionEnabled = false
for both of those sliders, and overlay an interactive slider on top:
Debug View Hierarchy:
When we remove the translucent background:
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.
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.