layoutIfNeeded function behaviour - ios

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)
}
}

Related

Applying CAGradientLayer on Dynamically Sized UITableViewCell

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
}

Trouble positioning a sublayer correctly during animation

The question is how should I define and set my shape layer's position and how should it be updated so that the layer appears where I'm expecting it to during the animation? Namely, the shape should be stuck on the end of the stick.
I have a CALayer instance called containerLayer, and it has a sublayer which is a CAShapeLayer instance called shape. containerLayer is supposed to place shape at a specific position unitLoc like this:
class ContainerLayer: CALayer, CALayerDelegate {
// ...
override func layoutSublayers() {
super.layoutSublayers()
if !self.didSetup {
self.setup()
self.didSetup = true
}
updateFigure()
setNeedsDisplay()
}
func updateFigure() {
figureCenter = self.bounds.center
figureDiameter = min(self.bounds.width, self.bounds.height)
figureRadius = figureDiameter/2
shapeDiameter = round(figureDiameter / 5)
shapeRadius = shapeDiameter/2
locRadius = figureRadius - shapeRadius
angle = -halfPi
unitLoc = CGPoint(x: self.figureCenter.x + cos(angle) * locRadius, y: self.figureCenter.y + sin(angle) * locRadius)
shape.bounds = CGRect(x: 0, y: 0, width: shapeDiameter, height: shapeDiameter)
shape.position = unitLoc
shape.updatePath()
}
// ...
}
I'm having trouble finding the right way to specify what this position should be before, and during a resize animation which changes containerLayer.bounds. I do understand that the problem I'm having is that I'm not setting the position in such a way that the animation will display it the way that I'm expecting it would.
I have tried using a CABasicAnimation(keyPath: "position") to animate the position, and it improved the result over what I had tried previously, but it's still off.
#objc func resize(sender: Any) {
// MARK:- animate containerLayer bounds & shape position
// capture bounds value before changing
let oldBounds = self.containerLayer.bounds
// capture shape position value before changing
let oldPos = self.containerLayer.shape.position
// update the constraints to change the bounds
isLarge.toggle()
updateConstraints()
self.view.layoutIfNeeded()
let newBounds = self.containerLayer.bounds
let newPos = self.containerLayer.unitLoc
// set up the bounds animation and add it to containerLayer
let baContainerBounds = CABasicAnimation(keyPath: "bounds")
baContainerBounds.fromValue = oldBounds
baContainerBounds.toValue = newBounds
containerLayer.add(baContainerBounds, forKey: "bounds")
// set up the position animation and add it to shape layer
let baShapePosition = CABasicAnimation(keyPath: "position")
baShapePosition.fromValue = oldPos
baShapePosition.toValue = newPos
containerLayer.shape.add(baShapePosition, forKey: "position")
containerLayer.setNeedsLayout()
self.view.layoutIfNeeded()
}
I also tried using the presentation layer like this to set the position, and it also seems to get it close, but it's still off.
class ViewController: UIViewController {
//...
override func loadView() {
super.loadView()
displayLink = CADisplayLink(target: self, selector: #selector(animationDidUpdate))
displayLink.add(to: RunLoop.main, forMode: RunLoop.Mode.default)
//...
}
#objc func animationDidUpdate(displayLink: CADisplayLink) {
let newCenter = self.containerLayer.presentation()!.bounds.center
let new = CGPoint(x: newCenter.x + cos(containerLayer.angle) * containerLayer.locRadius, y: newCenter.y + sin(containerLayer.angle) * containerLayer.locRadius)
containerLayer.shape.position = new
}
//...
}
class ContainerLayer: CALayer, CALayerDelegate {
// ...
func updateFigure() {
//...
//shape.position = unitLoc
//...
}
// ...
}
With some slight exaggeration, I was able to make it clearer what's happening in your code. In my example, the circle layer is supposed to remain 1/3 the height of the background view:
At the time the animation starts, the background view has already been set to its ultimate size at the end of the animation. You don't see that, because animation relies on portraying the layer's presentation layer, which is unchanged; but the view itself has changed. Therefore, when you position the shape of the shape layer, and you do it in terms of the view, you are sizing and positioning it at the place it will need to be when the animation ends. Thus it jumps to its final size and position, which makes sense only when we reach the end of the animation.
Okay, but now consider this:
Isn't that nicer? How is it done? Well, using the principles I have already described elsewhere, I've got a layer with a custom animatable property. The result is that on every frame of the animation, I get an event (the draw(in:) method for that layer). I respond to this by recalculating the path of the shape layer. Thus I am giving the shape layer a new path on every frame of the animation, and so it behaves smoothly. It stays in the right place, it resizes in smooth proportion to the size of the background view, and its stroke thickness remains constant throughout.

Animate layer on animated UIView

I'm having trouble animating a layer on one of my views. I have googled the issue, but only find answers using CATransaction which assumes that I know the fromValue and toValue of its bounds. I have a view in a tableHeader that resizes itself when clicked. This view is an ordinary UIView and animates just as expected in an UIView.animate()-block. This view has a CAGradientLayer as a sublayer, to give it a gradient backgroundcolor. When the view animates its height, the layer does not animate with it. The layer changes its bounds immediately when the animation starts.
To make sure the layer gets the right size overall (during init/loading/screen rotation etc.) I have been told to do this:
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = backgroundView.bounds
}
It gets the right size every time, but it never animates to it.
To do my view-animation, I do this:
self.someLabelHeightConstraint.constant = someHeight
UIView.animate(withDuration: 0.3, animations: { [weak self] in
self?.layoutIfNeeded()
})
which works perfectly, but I assume layoutIfNeeded() calls layoutSubviews at some point, which I assume will ruin any CALayer-animations I add into the block.
As you can see, I only change the constant of a constraint set on a view inside my view, so I actually don't know what the size of the actual header-view will be when the animation is completed. I could probably do some math to figure out what it'll be, but that seems unnecessary..
Are there no better ways to do this?
There are kludgy ways to update a sublayer's frame during an animation, but the most elegant solution is to let UIKit take care of this for you. Create a subclass UIView whose layerClass is a CAGradientLayer. When the view is created, the CAGradientLayer will be created for you. And when you animate the view's frame, the gradient layer is animated gracefully for you.
#IBDesignable
public class GradientView: UIView {
#IBInspectable public var startColor: UIColor = .white { didSet { updateColors() } }
#IBInspectable public var endColor: UIColor = .black { didSet { updateColors() } }
override open class var layerClass: AnyClass { return CAGradientLayer.self }
override public init(frame: CGRect = .zero) {
super.init(frame: frame)
config()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
config()
}
private func config() {
updateColors()
}
private func updateColors() {
let gradientLayer = layer as! CAGradientLayer
gradientLayer.colors = [startColor.cgColor, endColor.cgColor]
}
}
Note, I've made this #IBDesignable so you can put it in a framework target and then use it in IB, but that's up to you. That's not required. The main issue is the overriding of layerClass so that UIKit takes care of the animation of the layer as it animates the view.
You need to remove the actions of the CALayer
Add this code
gradientLayer.actions = ["position": NSNull(),"frame":NSNull(),"bounds":NSNull()]

Gradient at Top/Bot of Scrollview When There's Additional Content

I have a scrollview>ContentView>TextLabel setup where the gradient is showing up but not working how I want it to. It's a clear background scrollView, so all I'm looking for is a clear gradient mask over the text. I found something similar to I'm looking for in Objective-C but that thread doesn't have a Swift solution.
My end goal seems like something most people might use, so I'm surprised there's not a Swift version yet. The functionality I'm trying to code is:
Sometimes the text will fit perfectly in the scrollView's fixed size so there should be no gradient.
When the text is longer than can fit and so some of it is below the scrollView's bottom cutoff, the bottom of the view should fade to clear.
Once the user scrolls down and the top should fade, indicating that there's more above.
I tried this code to handle bullet #2:
let gradient = CAGradientLayer()
gradient.frame = self.bio_ScrollView.superview!.bounds ?? CGRectNull
gradient.colors = [UIColor.whiteColor().CGColor, UIColor.clearColor().CGColor]
//gradient.locations = [0, 0.15, 0.25, 0.75, 0.85, 1.0]
gradient.locations = [0.6, 1.0]
self.bio_ScrollView.superview!.layer.mask = gradient
But it fades everything, including the button below it, which is clearly not in the scrollview:
If I remove the .superview and apply it directly to the scrollView, it just fades all the text below the initial part that was visible:
Does anyone know anything about how to implement this correctly?
Figured it out. First, make sure you've added the right view hierarchy. The scroll view needs to be embedded in a container view (which is what the gradient will be applied to):
Top/container view: Set this up however you want
Scrollview: Pin all edges to container (4 total constraints)
Scrollview content view: Pin edges + Center X (5 total constraints)
Label: Pin edges (4 total constraints)
Add "scrollViewDelegate" to the ViewController class:
class ViewController_WithScrollView: UIViewController, UIScrollViewDelegate {
....
Connect the four views listed above with IBOutlets. Then, set your scrollView delegate within the viewDidLoad:
override func viewDidLoad() {
super.viewDidLoad()
yourScrollView.delegate = self
//+All your other viewDidLoad stuff
}
Then implement the scrollViewDidScroll func, which will run automatically thanks to the work you did above.
func scrollViewDidScroll (scrollView: UIScrollView) {
if scrollView.isAtTop {
self.applyGradient_To("Bot")
} else if scrollView.isAtBottom {
self.applyGradient_To("Top")
} else {
self.applyGradient_To("Both")
}
}
Then add this magical gradient code:
func applyGradient_To (state: String) {
let gradient = CAGradientLayer()
gradient.frame = self.yourScrollView.superview!.bounds ?? CGRectNull
switch state {
case "Top":
gradient.colors = [UIColor.clearColor().CGColor,UIColor.whiteColor().CGColor]
gradient.locations = [0.0,0.2]
case "Bot":
gradient.colors = [UIColor.whiteColor().CGColor, UIColor.clearColor().CGColor]
gradient.locations = [0.8,1.0]
default:
gradient.colors = [UIColor.clearColor().CGColor,UIColor.whiteColor().CGColor,UIColor.whiteColor().CGColor, UIColor.clearColor().CGColor]
gradient.locations = [0.0,0.2,0.8,1.0]
}
self.yourScrollView.superview!.layer.mask = nil
self.yourScrollView.superview!.layer.mask = gradient
}
That should do it!

Animate `backgroundColor` of a `UIView` that implements `drawRect`

I have a custom UIView and I would like to animate its backgroundColor property. This is an animatable property of a UIView.
This is the code:
class ETTimerUIView: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// other methods
func flashBg() {
UIView.animateWithDuration( 1.0, animations: {
self.backgroundColor = UIColor.colorYellow()
})
}
override func drawRect() {
// Something related to a timer I'm rendering
}
This code causes causes the animation to skip and the color to change immediately:
self.backgroundColor = UIColor.colorYellow() // Changes immediately to yellow
If I animate alpha, this animates from 1 to 0 over one second as expected:
self.alpha = 0 // animates
How do I animate a background color change in this situation?
Implementing drawRect blocks backgroundColor animation, but no answer is provided yet.
Maybe this is why you can't combine drawRect and animateWithDuration, but I don't understand it much.
I guess I need to make a separate view--should this go in the storyboard in the same view controller? programmatically created?
Sorry, I'm really new to iOS and Swift.
It is indeed not working when I try it, I had a related question where putting the layoutIfNeeded() method inside the animation worked and made the view smoothly animating (move button towards target using constraints, no reaction?). But in this case, with the backgroundColor, it does not work. If someone knows the answer I will be interested to know.
But if you need a solution right now, you could create a UIView (programmatically or via the storyboard) that is used only as a container. Then you add 2 views inside : one on top, and one below, with the same frame as the container. And you only change the alpha of the top view, which let the user see the view behind :
class MyView : UIView {
var top : UIView!
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.blueColor()
top = UIView(frame: CGRectMake(0,0, self.frame.width, self.frame.height))
top.backgroundColor = UIColor.yellowColor()
self.addSubview(top)
}
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
let sub = UIView(frame: CGRectMake(0,0, self.frame.width, self.frame.height))
sub.backgroundColor = UIColor.purpleColor()
self.sendSubviewToBack(sub)
UIView.animateWithDuration(1, animations: { () -> Void in
self.top.alpha = 0
}) { (success) -> Void in
println("anim finished")
}
}
}
The answer is that you cannot animate backgroundColor of a view that implements drawRect. I do not see docs for this anywhere (please comment if you know of one).
You can't animate it with animateWithDuration, nor with Core Animation.
This thread has the best explanation I've found yet:
When you implement -drawRect:, the background color of your view is then drawn into the associated CALayer, rather than just being set on the CALayer as a style property... thus prevents you from getting a contents crossfade
The solution, as #Paul points out, is to add another view above, behind, or wherever, and animate that. This animates just fine.
Would love a good understanding of why it is this way and why it silently swallows the animation instead of hollering.
Not sure if this will work for you, but to animate the background color of a UIView I add this to a UIView extension:
extension UIView {
/// Pulsates the color of the view background to white.
///
/// Set the number of times the animation should repeat, or pass
/// in `Float.greatestFiniteMagnitude` to pulsate endlessly.
/// For endless animations, you need to manually remove the animation.
///
/// - Parameter count: The number of times to repeat the animation.
///
func pulsate(withRepeatCount count: Float = 1) {
let pulseAnimation = CABasicAnimation(keyPath: "backgroundColor")
pulseAnimation.fromValue = <#source UIColor#>.cgColor
pulseAnimation.toValue = <#target UIColor#>.cgcolor
pulseAnimation.duration = 0.4
pulseAnimation.autoreverses = true
pulseAnimation.repeatCount = count
pulseAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
self.layer.add(pulseAnimation, forKey: "Pulsate")
CATransaction.commit()
}
}
When pasting this in to a source file in Xcode, replace the placeholders with your two desired colors. Or you can replace the entire lines with something like these values:
pulseAnimation.fromValue = backgroundColor?.cgColor
pulseAnimation.toValue = UIColor.white.cgColor

Resources