提问人:psb 提问时间:10/26/2023 更新时间:11/2/2023 访问量:54
自定义 UITableViewCell、UISlider 子视图意外的大小调整行为
Custom UITableViewCell, UISlider subview unexpected sizing behavior
问:
我在自定义UITableViewCell中有一个UISlider。
当我查看属性中滑块的大小时,显示的是滑块在情节提要中设置的大小,而不是视图出现时绘制的最终大小。awakeFromNib
.frame
我以为所有这些设置都已经完成,但滑块的大小似乎在 awakeFromNib 和它的最终外观之间发生了变化。awakeFromNib
我在 2015 年发现了一个类似的问题,该问题已经发布了答案,但实际上并没有得到解决。
我在 2016 年也发现了一个类似的问题,但这个问题似乎不适用于我的情况。
Swift UITableViewCell 子视图布局更新延迟
我添加了情节提要中设置的约束的屏幕截图。
答:
直到单元格(及其 UI 组件)的大小,我们才知道layoutSubviews()
因此,假设您将箭头位置设置为百分比,请在单元格类中按照以下行实现:layoutSubviews()
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
}
我假设您的所有行都将具有相同的滑块“时间范围”,因此我们可以得到这样的结果(我将拇指色调设置为半透明,将箭头 y 位置设置为对齐方式,以便我们可以看到对齐方式):
对于完整的示例(要生成该输出),请使用此 Storyboard https://pastebin.com/nUZFMtGN(由于此答案变得太长而不得不移动它)和以下代码:
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)
}
}
编辑
如果我们愿意,我们可以完全摆脱位置计算......layoutSubviews()
让我们从一个自定义滑块拇指图像开始,我们可以在运行时使用 SF 符号生成它 - 背景会很清晰:
如果我们将它与 一起使用,它将看起来像这样(为了清楚起见,我给它一个半透明的背景):.setThumbImage(arrowThumb, for: [])
例如,现在我们可以设置滑块的:
.minimumValue = 7.0 // (hours - 7:00 am)
.maximumValue = 23.0 // (hours - 11:00 pm)
,然后将该值设置为时间。
因此,我们可以将一个用于“开始时间”,并覆盖另一个用于“结束时间”:
如果我们然后设置:
.setMinimumTrackImage(UIImage(), for: [])
.setMaximumTrackImage(UIImage(), for: [])
在两个滑块上,我们得到这个:
我们将为这两个滑块进行设置,并在顶部覆盖一个交互式滑块:.isUserInteractionEnabled = false
调试视图层次结构:
当我们删除半透明背景时:
在这一点上,我们不再需要做任何事情......我们只需设置“startMarkerSlider”和“endMarkerSlider”,箭头标记就会自动定位。layoutSubviews()
.value
下面是该方法的示例代码 - 所有代码、no 或 connections...@IBOutlet
@IBAction
// 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 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()
//}
}
示例控制器类
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)
}
}
请注意,我还更改了使用数据的方法,因此我们直接处理对象。Date
评论
viewDidLoad
awakeFromNib