Background: My app allows users to select a gradient border to apply to UITableViewCells that are dynamically sized based on the content within them. I am currently creating this border by inserting a CAGradientLayer sublayer to a UIView that sits within the cell.
Issue: Because each cell is sized differently, I am resizing the CAGradientLayer by overriding layoutIfNeeded in my custom cell class. This works, but seems suboptimal because the border is being redrawn over and over again and flickers as the cell is resizing.
Link to Screen Capture:
https://drive.google.com/file/d/1SiuNozyUM7LCdYImZoGCWeoeBKu2Ulcw/view?usp=sharing
Question: Do I need to take a different approach to creating this border? Or am I missing something regarding the UITableViewCell lifecycle? I have come across similar issues on SO, but none that seem to address this redraw issue. Thank you for your help.
CAGradientLayer Extension to Create Border
extension CAGradientLayer {
func createBorder(view: UIView, colors: [CGColor]) {
self.frame = CGRect(origin: CGPoint.zero, size: view.bounds.size)
self.colors = colors
let shape = CAShapeLayer()
shape.lineWidth = 14
shape.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: 12).cgPath
shape.strokeColor = UIColor.black.cgColor
shape.fillColor = UIColor.clear.cgColor
self.mask = shape
}
}
TableViewCell Class - Insert CAGradientLayer
override func awakeFromNib() {
super.awakeFromNib()
reportCard.layer.insertSublayer(gradientLayer, at: 0)
...
}
TableViewCell Class - Resize the Border and Apply User Selected Design
override func layoutIfNeeded() {
super.layoutIfNeeded()
switch currentReport?.frameId {
case "sj_0099_nc_frame_001":
gradientLayer.createBorder(view: reportCard, colors: [App.BorderColors.lavender, App.BorderColors.white])
case "sj_0099_nc_frame_002":
gradientLayer.createBorder(view: reportCard, colors: [App.BorderColors.red, App.BorderColors.white])
case "sj_0099_nc_frame_003":
gradientLayer.createBorder(view: reportCard, colors: [App.BorderColors.yellow, App.BorderColors.white])
default:
gradientLayer.createBorder(view: reportCard, colors: [App.BorderColors.white, App.BorderColors.white])
}
}
Turns out I was looking in the wrong place all along. The code in my original post is functional, and updating the gradientLayer frame in layoutIfNeeded() or setNeedsLayout() rather than layoutSubviews() accurately draws the gradientLayer. Per Apple documentation, layoutSubviews() should not be called directly.
The source of the bug was not in my custom cell, but in my tableViewController. I had an extraneous call to reloadData().
Instead of inside awakeFromNib() use this
override func layoutSubviews() {
super.layoutSubviews()
reportCard.layer.insertSublayer(gradientLayer, at: 0)
reportCard.clipsToBounds = true
}
Related
I am trying to set an underline on my UITextFields. I have tried a couple of methods but none of them seem to work. After looking through a couple of websites, the most suggested method is the following:
extension UITextField {
func setUnderLine() {
let border = CALayer()
let width = CGFloat(0.5)
border.borderColor = UIColor.lightGray.cgColor
border.frame = CGRect(x: 0, y: self.frame.size.height - width, width: self.frame.size.width-10, height: self.frame.size.height)
border.borderWidth = width
self.layer.addSublayer(border)
self.layer.masksToBounds = true
}
}
I can't think of any reason as to why the code above would not work, but all the answers I saw were posted a couple of years ago.
Could someone please let me know what I am doing wrong?
One problem I see with the code that you posted is that it won't update the layer if the text field gets resized. Each time you call the setUnderLine() function, it adds a new layer, then forgets about it.
I would suggest subclassing UITextField instead. That code could look like this:
class UnderlinedTextField: UITextField {
let underlineLayer = CALayer()
/// Size the underline layer and position it as a one point line under the text field.
func setupUnderlineLayer() {
var frame = self.bounds
frame.origin.y = frame.size.height - 1
frame.size.height = 1
underlineLayer.frame = frame
underlineLayer.backgroundColor = UIColor.blue.cgColor
}
// In `init?(coder:)` Add our underlineLayer as a sublayer of the view's main layer
required init?(coder: NSCoder) {
super.init(coder: coder)
self.layer.addSublayer(underlineLayer)
}
// in `init(frame:)` Add our underlineLayer as a sublayer of the view's main layer
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.addSublayer(underlineLayer)
}
// Any time we are asked to update our subviews,
// adjust the size and placement of the underline layer too
override func layoutSubviews() {
super.layoutSubviews()
setupUnderlineLayer()
}
}
That creates a text field that looks like this:
(And note that if you rotate the simulator to landscape mode, the UnderlineTextField repositions the underline layer for the new text field bounds.)
Note that it might be easier to just add a UIView to your storyboard, pinned to the bottom of your text field and one pixel tall, using your desired underline color. (You'd set up the underline view using AutoLayout constraints, and give it a background color.) If you did that you wouldn't need any code at all.
Edit:
I created a Github project demonstrating both approaches. (link)
I also added a view-based underline to my example app. That looks like this:
I am trying to create custom table view cell which works fine in my other UIViewControllers. However, in one of my controllers, the shadow is not growing, I can barely see the shadow.
Here is an image of the shadow being shown in red, you can see it is barely visible.
My cell has a UIView added inside the contentView to creating floating cell effects - the same code and same storyboard layouts are being used across my controllers but this is the only table view where the shadow issue is occurring - so I must be missing something.
My addShadow extension:
extension UIView {
func addShadow(offset: CGSize, color: UIColor, radius: CGFloat, opacity: Float) {
layer.masksToBounds = false
layer.shadowOffset = offset
layer.shadowColor = color.cgColor
layer.shadowRadius = radius
layer.shadowOpacity = opacity
}
}
My awakeFromNib on the custom cell:
:: cellContentView is my UIView added to the base contentView of the cell.
override func awakeFromNib() {
super.awakeFromNib()
self.backgroundColor = .clear
self.selectionStyle = .none
cellContentView?.layer.masksToBounds = true
cellContentView?.round(corners: [.topLeft, .topRight, .bottomLeft, .bottomRight], radius: 10)
cellContentView?.addShadow(offset: CGSize(width: 40, height: 60), color: UIColor.red, radius: 10, opacity: 1)
cellContentView?.layer.shouldRasterize = true
}
Note: The .round is an extension being used on all my cells.
No matter what radius or offset I add for this shadow, it does not get bigger than the image. Also, none of my other cells in the their controllers require the shouldRasterize property to be set, but this does.
Does anyone know what is happening here?
Thanks :)
Edit
Strangely, if I add constraints around my view to keep the gaps large between my view and the cell content view, the background colour disappears - this is set to white in the storyboard.
You should call in the layoutSubviews method. because shadow should add after the view is uploaded
override func awakeFromNib() {
super.awakeFromNib()
//init methods
}
override public func layoutSubviews() {
super.layoutSubviews()
//Added shadow
self.reloadLayers()
}
private func reloadLayers() {
self.layer.cornerRadius = 5
self.addShadow(.TransactionCell)
}
I hope it helps
Content view will fill you cell, so you need to add shadow to view inside content view which has all your components inside it. Then add constraints to it with gap between that view and content view. Second, 40 and 60 properties for shadow is likely too large, when I said too large I mean unbelievable large, because gap between content views in cells are no more than 15 - 30 even less. so try it with much less values, while radius can remain 10 but you will see what value fit the best. If cell content view is your custom view just values will did the job if your view is not kind of transparent or any inside it, in that case it won't, and there is hard to fix that, I tried many libraries and custom codes and it is never ok.
squircleView.layer.cornerRadius = 40
squircleView.layer.cornerCurve = CALayerCornerCurve.continuous
squircleView.layer.shadowColor = UIColor.systemGray.cgColor
squircleView.layer.shadowOpacity = 0.7
squircleView.layer.shadowOffset = CGSize(width: 0, height: 0.5)
squircleView.layer.shadowRadius = 5
I'm struggling with UITableView. As you can see in this video in third section of table view third cell isn't display correctly. That happens when I dequeue my cell like that:
let cell = tableView.dequeueReusableCell(withIdentifier: MultipleSelectAnswerSurveyTableViewCellIdentifier, for: indexPath) as! MultipleSelectAnswerSurveyTableViewCell
cell.setup(answer: question.answers?[indexPath.row].value ?? "", isSelected: false, style: style, isLastInSection: indexPath.row == (question.answers?.count ?? 1) - 1)
return cell
Cell's setup() method:
func setup(answer: String, isSelected: Bool, style: Style, isLastInSection: Bool) {
self.isLastInSection = isLastInSection
selectionStyle = .none
backgroundColor = style.survey.singleSelectAnswerTableViewCell.backgroundColor
answerLabel.textColor = style.survey.singleSelectAnswerTableViewCell.answerLabelColor
answerLabel.font = style.survey.singleSelectAnswerTableViewCell.answerLabelFont
answerLabel.text = answer
addSubview(answerLabel)
addSubview(selectionIndicator)
answerLabel.snp.makeConstraints { make in
make.left.equalTo(8)
make.centerY.equalTo(selectionIndicator.snp.centerY)
make.top.equalTo(8)
make.bottom.equalTo(-8)
make.right.equalTo(selectionIndicator.snp.left).offset(-8)
}
selectionIndicator.snp.makeConstraints { make in
make.right.equalTo(-8)
make.top.greaterThanOrEqualTo(8)
make.bottom.lessThanOrEqualTo(-8)
make.width.height.equalTo(26)
}
}
self.isLastInSection variable is used inside layoutSubviews():
override func layoutSubviews() {
super.layoutSubviews()
if isLastInSection {
roundCorners(corners: [.bottomLeft, .bottomRight], radius: 16.0)
}
contentView.layoutIfNeeded()
}
And finally roundCorners():
extension UIView {
func roundCorners(corners: UIRectCorner, radius: CGFloat) {
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
let mask = CAShapeLayer()
mask.path = path.cgPath
layer.mask = mask
}
}
When I dequeue cell with isLastInSection set to false cell is being displayed as expected (related video). So I think the problem is in life cycle of the cell and when the layoutSubview() is being called. I tried many solutions for similar problem found in different threads but none of them helped me. tableView(_:heightForRowAt:) causes the third cell to display correctly, but the first one has rounded bottom corners. Also all of them are fixed in height and that cannot happen.
But what is really weird: when I print the isLastInSection during dequeueing cell which is unexpectedly rounded debugger returns me false:
(lldb) po indexPath.row == (question.answers?.count ?? 1) - 1
false
As you can see in Debug View Hierarchy view text exists so that's why I 've defined the problem as hiding part of content.
You dequeue cell and each time you add subviews you don't check if they are already there which will happen in case of recycled cell. That probably breaks constraints and causes incorrect sizing.
Same problem with rounding - you set rounded corners, but you never revert this behavior when reused cell should not be rounded.
Best way to solve this issue would be to add additional check and create subviews only once:
func setup(answer: String, isSelected: Bool, style: Style, isLastInSection: Bool) {
if self.subviews.count == 0 {
// adding subviews etc.
// code that needs to be performed only once for whole cell's life
}
self.isLastInSection = isLastInSection
// set up content that changes for each cell (like text)
// for example a code depending on parameters of this method
}
alternatively you could keep some property like isInitialized and check that at the beginning.
Also your method layoutSubviews must support both cases:
override func layoutSubviews() {
super.layoutSubviews()
if isLastInSection {
roundCorners(corners: [.bottomLeft, .bottomRight], radius: 16.0)
} else {
layer.mask = nil
}
contentView.layoutIfNeeded()
}
I am having a problem with one of my table views. I am writing a messaging page for my app that uses a table view to display the messages sent and received. The table cells need to change height based on each cells content. I have the sizing working correctly but I now need to round the cells edges to fit the UI design. The way that I have done this in the past with non-dynamic heights is by calling a function to round each corner in the override function "layoutSubViews()" in the tableViewCell:
func roundAllCorners(radius: CGFloat) {
let allCorners: UIRectCorner = [.topLeft, .bottomLeft, .bottomRight, .topRight]
let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: allCorners, cornerRadii: CGSize(width: radius, height: radius))
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
If I try calling this function but the cell is dynamically sized then the left edge cuts off half a centimeter. If you scroll the cell off screen and back again though it fixes it. Hope you can find a solution to my problem, has been a pain in the neck for a while. Thanks.
It might be you also need to override the setter for frame and call it in there. Any any case this is not a good idea for multiple reasons. The thing is that table view cell has many views (including itself being a view) like content view and background view...
I suggest that you add yet another view on the content view which holds all your cell views. Then make this view a subclass and handle all the rounding in there. So from the storyboard perspective you would have something like:
- UITableViewCell
- contentView
- roundedContainer
- imageView
- button
- label
...
The rounded view has (or should have) constraints so layoutSubViews should be enough to override for setting up corner radius.
You can have a neat class you can use to round your view like:
class RoundedView: UIView {
#IBInspectable var cornerRadius: CGFloat = 0.0 {
didSet {
refresh()
}
}
#IBInspectable var fullyRounded: Bool = false {
didSet {
refresh()
}
}
override func layoutSubviews() {
super.layoutSubviews()
refresh()
}
private func refresh() {
layer.cornerRadius = fullyRounded ? min(bounds.width, bounds.height) : cornerRadius
}
}
As already mentioned by #iDeveloper it might be better to use cornerRadius of a layer. But if you need to use a shape layer you can do that as well in this class.
Make sure to clip bounds on this view.
Make sure you RELOAD THE TABLEVIEW after calling your function
yourTableView.reloadData()
You can use self sizing table view cell according to the content. Now you can follow the previous implementation for rounded corner cell.
Place the below code inside viewDidLoad.
tableView.estimatedRowHeight = YourEstimatedTableViewHeight
tableView.rowHeight = UITableViewAutomaticDimension
Note: You have to give the top and bottom constraints to the content properly.
For detailed implementation you can follow self-sizing-table-view-cells
I am using a lot of gradient drawing using this function:
func drawGradient(colors: [CGColor], locations: [NSNumber]) {
let gradientLayer = CAGradientLayer()
gradientLayer.frame.size = self.frame.size
gradientLayer.frame.origin = CGPoint(x: 0.0,y: 0.0)
gradientLayer.colors = colors
gradientLayer.locations = locations
print(self.frame.size)
self.layer.insertSublayer(gradientLayer, at: 0)
}
The problem is that if I don't call self.view.layoutIfNeeded() in viewDidLoad() of UIViewController my gradient doesn't cover whole screen on iPhone X+. But if I call self.view.layoutIfNeeded() it makes my app crash on iOS 9.x and act weird on iPhone 5/5s. I really do not know any workaround and need help to understand how it all works.
You are calling drawGradient in viewDidLoad. That is too early. You need to wait until Auto Layout has sized the frame.
Move the call in viewDidLoad to an override of viewDidLayoutSubviews. Be careful though because viewDidLayoutSubviews is called more than once, so make sure you only call drawGradient once. You can add a property to your viewController called var appliedGradient = false and then check it before applying the gradient and flip it to true.
For your custom subclasses of UITableViewCell and UICollectionViewCell, override layoutSubviews and call drawGradient after super.layoutSubviews(). Again, make sure you only call it once.
Note: If your frame could resize (due to rotation of the phone) or differing cell sizes, you should keep track of the previous gradient layer and replace it with a new one in viewDidLayoutSubviews for your viewController and in layoutSubviews for your cells.
Here I've modified your drawGradient to make a global function called applyGradient that adds a gradient to a view. It replaces a previous gradient layer if there was one:
func applyGradient(colors: [CGColor], locations: [NSNumber], to view: UIView, replacing prior: CALayer?) -> CALayer {
let gradientLayer = CAGradientLayer()
gradientLayer.frame.size = view.frame.size
gradientLayer.frame.origin = CGPoint(x: 0.0,y: 0.0)
gradientLayer.colors = colors
gradientLayer.locations = locations
print(view.frame.size)
if let prior = prior {
view.layer.replaceSublayer(prior, with: gradientLayer)
} else {
view.layer.insertSublayer(gradientLayer, at: 0)
}
return gradientLayer
}
And it is used like this:
class ViewController: UIViewController {
// property to keep track of the gradient layer
var gradient: CALayer?
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
gradient = applyGradient(colors: [UIColor.red.cgColor, UIColor.yellow.cgColor],
locations: [0.0, 1.0], to: self.view, replacing: gradient)
}
}