Strange animation occurs when extending UIView - swift

I have a XUIView class as below. When I run animation, it's no effect for folding.
Who can explain me ?
class ViewController: UIViewController {
// Width constraint of XUIView instance
#IBOutlet weak var vwWrapperWidth: NSLayoutConstraint! {
didSet{
self.vwWrapperWidth.constant = UIScreen.main.bounds.width
}
}
#IBAction func btnToggleTouchUp(_ sender: UIButton) {
if(self.vwWrapperWidth.constant == 55) {
// animation effect is OK when expanding
self.vwWrapperWidth.constant = UIScreen.main.bounds.width
UIView.animate(withDuration: 0.5, animations: {
self.view.layoutIfNeeded()
})
}
else {
// animation effect is not OK when folding
self.vwWrapperWidth.constant = 55
UIView.animate(withDuration: 0.5, animations: {
self.view.layoutIfNeeded()
})
}
}
//.....
}
#IBDesignable
class XUIView: UIView {
#IBInspectable
var roundTopLeftCorner: Bool = false
#IBInspectable
var roundBottomLeftCorner: Bool = false
#IBInspectable
var roundTopRightCorner: Bool = false
#IBInspectable
var roundBottomRightCorner: Bool = false
#IBInspectable
var cornerRadius: CGFloat = 0.0
#IBInspectable
var borderWidth: CGFloat = 0.0
#IBInspectable
var borderColor: UIColor?
fileprivate var borderLayer: CAShapeLayer? {
didSet{
self.layer.addSublayer(self.borderLayer!)
}
}
func roundCorners(_ corners: UIRectCorner) {
if(self.borderLayer == nil) { self.borderLayer = CAShapeLayer() }
let bounds = self.bounds
let maskPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: self.cornerRadius, height: self.cornerRadius))
let maskLayer = CAShapeLayer()
maskLayer.frame = bounds
maskLayer.path = maskPath.cgPath
self.layer.mask = maskLayer
self.borderLayer?.frame = bounds
self.borderLayer?.path = maskPath.cgPath
self.borderLayer?.strokeColor = self.borderColor?.cgColor
self.borderLayer?.lineWidth = self.borderWidth
self.borderLayer?.fillColor = nil
}
override func layoutSubviews() {
super.layoutSubviews()
var roundedCorners: UIRectCorner = []
if(roundTopLeftCorner) { roundedCorners.insert(.topLeft) }
if(roundTopRightCorner) { roundedCorners.insert(.topRight) }
if(roundBottomLeftCorner) { roundedCorners.insert(.bottomLeft) }
if(roundBottomRightCorner) { roundedCorners.insert(.bottomRight) }
roundCorners(roundedCorners)
}
}
source code : http://www.mediafire.com/file/n6svp1mk44fc0uf/TestXUIView.zip/file

You are experiencing no animation on the "folding" animation because CALayer's path property is not implicitly animatable. It is mentioned here.
Unlike most animatable properties, path (as with all CGPath animatable properties) does not support implicit animation.
To make matters worst, CALayer's frame property is not implicitly animatable either. It is mentioned here
Note: The frame property is not directly animatable. Instead you should animate the appropriate combination of the bounds, anchorPoint and position properties to achieve the desired result.
This simply means the following lines won't work, just because it is being called inside a UIView.animate block.
let maskLayer = CAShapeLayer()
maskLayer.frame = bounds // This line won't animate!
maskLayer.path = maskPath.cgPath // This line won't animate!
self.layer.mask = maskLayer
Since it is not being animated, the mask is applied as soon as the layoutSubviews method in XUIView is called. View is actually animating, but you just cannot see it because the mask is being set before the animation completes.
This is also what happens in the expand animation. First the mask expands, then the animation occurs.
To prevent this, you might need to find a way to animate the width constraint and the layer frames & mask paths together.

The problem is that you are creating a CAShapeLayer in layoutSubviews which means that every time an animation occurs it gets call.
Try commenting line 91 and you are going to get what you want.

Related

Setting the frame of a transformed CALayer

I'm currently playing with CALayers a bit. For demo purposes I'm creating a custom UIView that renders a fuel gauge. The view has two sub-layers:
one for the background
one for the hand
The layer that represents the hand is then simple rotated accordingly to point at the correct value. So far, so good. Now I want the view to resize its layers whenever the size of the view is changed. To achieve this, I created an override of the layoutSubviews method like this:
public override func layoutSubviews()
{
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds)
{
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
As the method is being called many times, I'm using previousBounds to make sure I only perform the update on the layers when the size has actually changed.
At first, I had just the following code in the updateLayers method to set the frames of the sub-layers:
backgroundLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
That worked fine - until the handLayer was rotated. In that case some weird things happen to its size. I suppose it is because the frame gets applied after the rotation and of course, the rotated layer doesn't actually fit the bounds and is thus resized to fit.
My current solution is to temporarily create a new CATransaction that suppresses animations, reverting the transformation back to identity, setting the frame and then re-applying the transformation like this:
CATransaction.begin()
CATransaction.setDisableActions(true)
let oldTransform = scaleLayer.transform
handLayer.transform = CATransform3DIdentity
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.transform = oldTransform
CATransaction.commit()
I already tried omitting the CATransaction and instead applying the handLayer.affineTransform to the bounds I'm setting, but that didn't yield the expected results - maybe I did it wrong (side question: How to rotate a given CGRect around its center without doing all the maths myself)?
My question is simply: Is there a recommended was of setting the frame of a transformed layer or is the solution I found already "the" way to do it?
EDIT
Kevvv provided some sample code, which I've modified to demonstrate my problem:
class ViewController: UIViewController {
let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
let backgroundLayer = CALayer()
let handLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(customView)
customView.backgroundColor = UIColor.blue
backgroundLayer.backgroundColor = UIColor.yellow.cgColor
backgroundLayer.frame = customView.bounds
let handPath = UIBezierPath()
handPath.move(to: backgroundLayer.position)
handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
handLayer.frame = customView.bounds
handLayer.path = handPath.cgPath
handLayer.strokeColor = UIColor.black.cgColor
handLayer.backgroundColor = UIColor.red.cgColor
customView.layer.addSublayer(backgroundLayer)
customView.layer.addSublayer(handLayer)
handLayer.transform = CATransform3DMakeRotation(5, 0, 0, 1)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
customView.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
customView.frame = customView.frame.insetBy(dx:10, dy:10)
/*let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
let fromValue = self.handLayer.transform
let toValue = CGFloat.pi * 2
animation.duration = 2
animation.fromValue = fromValue
animation.toValue = toValue
animation.valueFunction = CAValueFunction(name: .rotateZ)
self.handLayer.add(animation, forKey: nil)*/
}
}
class CustomView: UIView {
var previousBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
func updateLayers(_ bounds: CGRect) {
guard let sublayers = self.layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
}
}
}
If you add this to a playground, then run and tap the control, you'll see what I mean. Watch the red "square".
Do you mind explaining what the "weird things happening to the size" means? I tried to replicate it, but couldn't find the unexpected effects:
class ViewController: UIViewController {
let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
let backgroundLayer = CALayer()
let handLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(customView)
backgroundLayer.backgroundColor = UIColor.yellow.cgColor
backgroundLayer.frame = customView.bounds
let handPath = UIBezierPath()
handPath.move(to: backgroundLayer.position)
handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
handLayer.frame = customView.bounds
handLayer.path = handPath.cgPath
handLayer.strokeColor = UIColor.black.cgColor
customView.layer.addSublayer(backgroundLayer)
customView.layer.addSublayer(handLayer)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
customView.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
let fromValue = self.handLayer.transform
let toValue = CGFloat.pi * 2
animation.duration = 2
animation.fromValue = fromValue
animation.toValue = toValue
animation.valueFunction = CAValueFunction(name: .rotateZ)
self.handLayer.add(animation, forKey: nil)
}
}
class CustomView: UIView {
var previousBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
func updateLayers(_ bounds: CGRect) {
guard let sublayers = self.layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
}
}
}
Edit
I think the issue is that the red box is resized with a frame. Since a frame is always upright even if it's rotated, if you were to do an inset from a frame, it'd look like this:
However, if you were to resize the red box with bounds:
sublayer.bounds = bounds.insetBy(dx: 5, dy: 5)
sublayer.position = self.convert(self.center, from: self.superview)
instead of:
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
You'll probably have to re-center the handPath and everything else in it accordingly as well.

Swift - Creating shadow with 2 different colours for imageView

I'm wondering how to create a shadow with two different colours for an imageView. For example the top and left side has a different colour than the right and bottom side of the imageView.
To get different color shadows - one going up-left and one going down-right - on a UIImageView, one approach would be:
Subclass UIView
Give it 3 CALayer sublayers
Shadow 1 layer
Shadow 2 layer
Image layer
This also makes it easy to add rounded corners.
Here is a sample class. It has #IBInspectable properties to set the image, corner radius, shadow colors and shadow offsets. It is also marked #IBDesignable so you can see how it looks while designing in Storyboard / Interface Builder:
#IBDesignable
class DoubleShadowRoundedImageView: UIView {
#IBInspectable var image: UIImage? = nil {
didSet {
imageLayer.contents = image?.cgImage
}
}
#IBInspectable var cornerRadius: CGFloat = 0.0
#IBInspectable var shad1X: CGFloat = 0.0
#IBInspectable var shad1Y: CGFloat = 0.0
#IBInspectable var shad2X: CGFloat = 0.0
#IBInspectable var shad2Y: CGFloat = 0.0
#IBInspectable var shad1Color: UIColor = UIColor.blue
#IBInspectable var shad2Color: UIColor = UIColor.red
var imageLayer: CALayer = CALayer()
var shadowLayer1: CALayer = CALayer()
var shadowLayer2: CALayer = CALayer()
var shape: UIBezierPath {
return UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
}
var shapeAsPath: CGPath {
return shape.cgPath
}
var shapeAsMask: CAShapeLayer {
let s = CAShapeLayer()
s.path = shapeAsPath
return s
}
override func layoutSubviews() {
super.layoutSubviews()
clipsToBounds = false
backgroundColor = .clear
self.layer.addSublayer(shadowLayer1)
self.layer.addSublayer(shadowLayer2)
self.layer.addSublayer(imageLayer)
imageLayer.frame = bounds
imageLayer.mask = shapeAsMask
shadowLayer1.frame = bounds
shadowLayer2.frame = bounds
shadowLayer1.shadowPath = (image == nil) ? nil : shapeAsPath
shadowLayer1.shadowOpacity = 0.80
shadowLayer2.shadowPath = (image == nil) ? nil : shapeAsPath
shadowLayer2.shadowOpacity = 0.80
shadowLayer1.shadowColor = shad1Color.cgColor
shadowLayer2.shadowColor = shad2Color.cgColor
shadowLayer1.shadowOffset = CGSize(width: shad1X, height: shad1Y)
shadowLayer2.shadowOffset = CGSize(width: shad2X, height: shad2Y)
}
}
You would probably want to change some of the default values, and you might want to add some additional properties (such as shadow opacity).
Example results:

UIProgressView setProgress issue

I've encountered an issue using a UIProgressView where low values (1% - about 10%) look off. You can see with the example above that 97% looks accurate while 2% does not.
Here's the code for setting colors:
self.progressView.trackTintColor = UIColor.green.withAlphaComponent(0.3)
self.progressView.tintColor = UIColor.green.withAlphaComponent(1.0)
But, if I comment out the trackTintColor or the tintColor, then the 2% looks correct. Why when using these together does it cause this issue? Just an Xcode bug? Has anyone resolved this before?
I've experienced the same issue in my project. For me it's fixed by using progressTintColor instead of tintColor.
progressView.progressTintColor = UIColor.green.withAlphaComponent(1.0)
progressView.trackTintColor = UIColor.green.withAlphaComponent(0.3)
you need to create color image
SWIFT 3 Example:
class ViewController: UIViewController {
#IBOutlet weak var progressView: UIProgressView!
#IBAction func lessButton(_ sender: UIButton) {
let percentage = 20
let invertedValue = Float(100 - percentage) / 100
progressView.setProgress(invertedValue, animated: true)
}
#IBAction func moreButton(_ sender: UIButton) {
let percentage = 80
let invertedValue = Float(100 - percentage) / 100
progressView.setProgress(invertedValue, animated: true)
}
override func viewDidLoad() {
super.viewDidLoad()
//create gradient view the size of the progress view
let gradientView = GradientView(frame: progressView.bounds)
//convert gradient view to image , flip horizontally and assign as the track image
progressView.trackImage = UIImage(view: gradientView).withHorizontallyFlippedOrientation()
//invert the progress view
progressView.transform = CGAffineTransform(scaleX: -1.0, y: -1.0)
progressView.progressTintColor = UIColor.black
progressView.progress = 1
}
}
extension UIImage{
convenience init(view: UIView) {
UIGraphicsBeginImageContext(view.frame.size)
view.layer.render(in: UIGraphicsGetCurrentContext()!)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
self.init(cgImage: (image?.cgImage)!)
}
}
#IBDesignable
class GradientView: UIView {
private var gradientLayer = CAGradientLayer()
private var vertical: Bool = false
override func draw(_ rect: CGRect) {
super.draw(rect)
// Drawing code
//fill view with gradient layer
gradientLayer.frame = self.bounds
//style and insert layer if not already inserted
if gradientLayer.superlayer == nil {
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = vertical ? CGPoint(x: 0, y: 1) : CGPoint(x: 1, y: 0)
gradientLayer.colors = [UIColor.green.cgColor, UIColor.red.cgColor]
gradientLayer.locations = [0.0, 1.0]
self.layer.insertSublayer(gradientLayer, at: 0)
}
}
}

How can I animate a gradient layer which increases its width?

I'm using a simple UIView which I want to animate and I added a gradient layer to it.
I want to increase the width of the view and the layer placed on the view,
but all I get is that the view increases its width but not the layer.
Example: Let's say I have a UIView with height = width = 50
I animate it by setting the width to: width += 50. This animation is working. If I do the same with layer then noting happens. The layer does not increase its width. I tried some things to fix this (see comments in code) but nothing is working.
Here is my code
func performNextTitleAnimation() {
let overlayViewHeight = overlayView.frame.size.height
let overlayViewWidth = overlayView.frame.size.width
let animationHeight: CGFloat = 48
let overlayViewHalfHeight = (overlayViewHeight) / 2
swipeAnimation = UIView(frame: CGRect(x: 0, y: overlayViewHalfHeight - (animationHeight/2), width: 48, height: animationHeight))
swipeAnimation.backgroundColor = .gray
swipeAnimation.layer.cornerRadius = swipeAnimation.frame.size.height / 2
gradientLayer.frame = swipeAnimation.bounds
overlayView.addSubview(swipeAnimation)
swipeAnimation.layer.addSublayer(gradientLayer)
UIView.animate(withDuration: 1.2, delay: 0, options: [.repeat], animations: {
self.gradientLayer.cornerRadius = 24
self.swipeAnimation.frame.size.width += 50
//Things I tried, but not working
//1.) self.gradientLayer.frame.size.width += 50
//2.) self.gradientLayer.frame.size.width = self.swipeAnimation.frame.size.width
//3.) self.gradientLayer.bounds = self.swipeAnimation.bounds
}, completion: nil)
}
Gradient Layer
gradientLayer.colors = [UIColor.white.cgColor, animationColor.cgColor]
gradientLayer.locations = [0.0, 1.0]
gradientLayer.transform = CATransform3DMakeRotation(3*(CGFloat.pi) / 2, 0, 0, 1)
Any help is highly appreciated.
The best result I've ever achieved when I needed to animate CAGrandientLayers size is to use it as a layerClass of a custom UIView:
class GrandientView: UIView {
override class var layerClass : AnyClass {
return CAGradientLayer.self
}
var gradientLayer: CAGradientLayer {
// it is safe to force cast here
// since we told UIView to use this exact type
return self.layer as! CAGradientLayer
}
override init(frame: CGRect) {
super.init(frame: frame)
// setup your gradient
}
}

How to set a border only for one of the edges of UIView

In swift, is there a way to only set a border for top side of a UIView ?
There are many ways, but drawing the border yourself might offer a little more control. I would recommend subclassing a UIView and use a CAShapeLayer to accomplish this.
Something to the effect of (using Swift 3):
import UIKit
class TopBorderedView: UIView {
//decalare a private topBorder
fileprivate weak var topBorder: CAShapeLayer?
//declare a border thickness to allow outside access to setting it
var topThickness: CGFloat = 1.0 {
didSet {
drawTopBorder()
}
}
//declare public color to allow outside access
var topColor: UIColor = UIColor.lightGray {
didSet {
drawTopBorder()
}
}
//implment the draw method
fileprivate func drawTopBorder() {
let start = CGPoint(x: 0, y: 0)
let end = CGPoint(x: bounds.width, y: 0)
removeIfNeeded(topBorder)
topBorder = addBorder(from: start, to: end, color: topColor, thickness: topThickness)
}
//implement a private border drawing method that could be used for border on other sides if desired, etc..
fileprivate func addBorder(from: CGPoint, to: CGPoint, color: UIColor, thickness: CGFloat) -> CAShapeLayer {
let border = CAShapeLayer()
let path = UIBezierPath()
path.move(to: start)
path.addLine(to: end)
border.path = path.cgPath
border.strokeColor = color.cgColor
border.lineWidth = thickness
border.fillColor = nil
layer.addSublayer(border)
return border
}
//used to remove the border and make room for a redraw to be autolayout friendly
fileprivate func removeIfNeeded(_ border: CAShapeLayer?) {
if let bdr = border {
bdr.removeFromSuperlayer()
}
}
//override layoutSubviews() (probably debatable) and call the drawTopBorder method to draw and redraw if needed
override func layoutSubviews() {
super.layoutSubviews()
drawTopBorder()
}
}
For maximum reusability in the context of storyboards - I would also take a look at using #IBDesignable and #IBInspectable for common UI patterns like this. For a decent intro, checkout NSHipster: IBDesignable and IBInspectable