patternswiftMinor
UIButton subclass with animated 'shimmer' effect
Viewed 0 times
effectwithanimatedshimmeruibuttonsubclass
Problem
In earlier versions of iOS the lock screen had a 'slide to unlock' element which I'm referencing as a 'shimmer' effect.
The effect I'm looking for is simpler:
Here's an example of my
Example usage:
```
let shimmer = ShimmerButton(frame: .zero)
shimmer.bac
The effect I'm looking for is simpler:
- Button starts with single color (e.g. blue)
- a band of color (e.g. red) sweeps across the text label from left to right
- Repeat
Here's an example of my
ShimmerButton class in action, and the code itself: class ShimmerButton: UIButton {
private let wrapperLayer = CALayer()
private let gradientLayer = CAGradientLayer()
var gradientColors: [UIColor] = [] {
didSet {
gradientLayer.colors = gradientColors.map({ $0.cgColor })
}
}
override func layoutSubviews() {
super.layoutSubviews()
// only needs to be set once, but no harm (?) in setting multiple times
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
wrapperLayer.addSublayer(gradientLayer)
layer.insertSublayer(wrapperLayer, at: 0)
wrapperLayer.mask = titleLabel?.layer
// update sublayers based on new frame
wrapperLayer.frame = frame
gradientLayer.frame.size = CGSize(width: frame.width * 4, height: frame.height)
// remove any existing animation, and re-create for new size
let animationKeyPath = "position.x"
gradientLayer.removeAnimation(forKey: animationKeyPath)
let animation: CABasicAnimation = CABasicAnimation(keyPath: animationKeyPath)
animation.fromValue = bounds.width - gradientLayer.bounds.width / 2
animation.toValue = gradientLayer.bounds.width / 2
animation.duration = 3
animation.repeatCount = HUGE
animation.fillMode = kCAFillModeForwards
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
gradientLayer.add(animation, forKey: animationKeyPath)
}
}Example usage:
```
let shimmer = ShimmerButton(frame: .zero)
shimmer.bac
Solution
Okay, so sometimes posting a question is the best way to figure out an answer yourself .
I was looking for an alternative to having the gradient colors evenly spaced (my hack was to repeat the colors e.g. "blue blue red blue blue") and found the
Animating this property feels like a much better approach because with the position not changing I can remove the wrapper layer.
Then, with without the wrapper layer I realized that I could override
The only thing that feels a little strange is the forced requirement for for gradient colors to have three colors (otherwise I'd need to figure some formula to derive values for
Edit: I've updated answer so instead of directly setting the colors, I've exposed
I've created a protocol that captures the properties used to define the shimmer effect, and also provide a default implementation of the animation.
It wasn't a requirement from my original question, but moving this code out of a specific subclass makes this snippet of code reusable (and maintainable) across other subclasses (e.g. UIView, UILabel).
Default implementation:
In the UIButton subclass I've added property observers to each of the properties that calls
I also considered just supplying default values and requiring
```
class ShimmerButton: UIButton, ShimmerEffect {
override static var laye
I was looking for an alternative to having the gradient colors evenly spaced (my hack was to repeat the colors e.g. "blue blue red blue blue") and found the
locations property on CAGradientLayer which is also animatable. Animating this property feels like a much better approach because with the position not changing I can remove the wrapper layer.
Then, with without the wrapper layer I realized that I could override
layerClass so the buttons backing layer is a gradient layer, which will also resize as needed when the view frame changes so that I don't even need to override layoutSubviews. The only thing that feels a little strange is the forced requirement for for gradient colors to have three colors (otherwise I'd need to figure some formula to derive values for
locations).Edit: I've updated answer so instead of directly setting the colors, I've exposed
gradientTint and gradientHighlight properties that are used to set the gradients colors array.I've created a protocol that captures the properties used to define the shimmer effect, and also provide a default implementation of the animation.
It wasn't a requirement from my original question, but moving this code out of a specific subclass makes this snippet of code reusable (and maintainable) across other subclasses (e.g. UIView, UILabel).
protocol ShimmerEffect {
var animationDuration: TimeInterval { set get }
var animationDelay: TimeInterval {set get }
var gradientTint: UIColor { set get }
var gradientHighlight: UIColor { set get }
//// Expects value between 0.0—1.0 that represents
//// the ratio of the gradient highlight to the full
//// width of the gradient.
var gradientHighlightRatio: Double { set get }
//// The layer that the gradient will be applied to
var gradientLayer: CAGradientLayer { get }
}Default implementation:
extension ShimmerEffect {
/// Configures, and adds the animation to the gradientLayer
func addShimmerAnimation() {
// `gradientHighlightRatio` represents how wide the highlight
// should be compared to the entire width of the gradient and
// is used to calculate the positions of the 3 gradient colors.
// If the highlight is 20% width of the gradient, then the
// 'start locations' would be [-0.2, -0.1, 0.0] and the
// 'end locations' would be [1.0, 1.1, 1.2]
let startLocations = [NSNumber(value: -gradientHighlightRatio), NSNumber(value: -gradientHighlightRatio/2), 0.0]
let endLocations = [1, NSNumber(value: 1+(gradientHighlightRatio/2)), NSNumber(value: 1+gradientHighlightRatio)]
let gradientColors = [gradientTint.cgColor, gradientHighlight.cgColor, gradientTint.cgColor]
// If the gradient highlight ratio is wide, then it can
// 'bleed' over into the visible space of the view, which
// looks particularly bad if there is a pause between the
// animation repeating.
// Shifting the start and end points of the gradient by the
// size of the highlight prevents this.
gradientLayer.startPoint = CGPoint(x: -gradientHighlightRatio, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1+gradientHighlightRatio, y: 0.5)
gradientLayer.locations = startLocations
gradientLayer.colors = gradientColors
let animationKeyPath = "locations"
let shimmerAnimation = CABasicAnimation(keyPath: animationKeyPath)
shimmerAnimation.fromValue = startLocations
shimmerAnimation.toValue = endLocations
shimmerAnimation.duration = animationDuration
shimmerAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
let animationGroup = CAAnimationGroup()
animationGroup.duration = animationDuration + animationDelay
animationGroup.repeatCount = .infinity
animationGroup.animations = [shimmerAnimation]
// removes animation with same key (if exists) then adds
// the new animation
gradientLayer.removeAnimation(forKey: animationKeyPath)
gradientLayer.add(animationGroup, forKey: animationKeyPath)
}
}In the UIButton subclass I've added property observers to each of the properties that calls
addShimmerAnimation() with any property change. I also considered just supplying default values and requiring
addShimmerAnimation() to be called manually once properties were configured. Another route was not having any public properties exposed and instead passing everything in through an initializer, but that would remove the possibility of these classes being used in a storyboard (which is an option I like to leave open) and having properties exposed through tagging the properties with IBInspectable.```
class ShimmerButton: UIButton, ShimmerEffect {
override static var laye
Code Snippets
protocol ShimmerEffect {
var animationDuration: TimeInterval { set get }
var animationDelay: TimeInterval {set get }
var gradientTint: UIColor { set get }
var gradientHighlight: UIColor { set get }
//// Expects value between 0.0—1.0 that represents
//// the ratio of the gradient highlight to the full
//// width of the gradient.
var gradientHighlightRatio: Double { set get }
//// The layer that the gradient will be applied to
var gradientLayer: CAGradientLayer { get }
}extension ShimmerEffect {
/// Configures, and adds the animation to the gradientLayer
func addShimmerAnimation() {
// `gradientHighlightRatio` represents how wide the highlight
// should be compared to the entire width of the gradient and
// is used to calculate the positions of the 3 gradient colors.
// If the highlight is 20% width of the gradient, then the
// 'start locations' would be [-0.2, -0.1, 0.0] and the
// 'end locations' would be [1.0, 1.1, 1.2]
let startLocations = [NSNumber(value: -gradientHighlightRatio), NSNumber(value: -gradientHighlightRatio/2), 0.0]
let endLocations = [1, NSNumber(value: 1+(gradientHighlightRatio/2)), NSNumber(value: 1+gradientHighlightRatio)]
let gradientColors = [gradientTint.cgColor, gradientHighlight.cgColor, gradientTint.cgColor]
// If the gradient highlight ratio is wide, then it can
// 'bleed' over into the visible space of the view, which
// looks particularly bad if there is a pause between the
// animation repeating.
// Shifting the start and end points of the gradient by the
// size of the highlight prevents this.
gradientLayer.startPoint = CGPoint(x: -gradientHighlightRatio, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1+gradientHighlightRatio, y: 0.5)
gradientLayer.locations = startLocations
gradientLayer.colors = gradientColors
let animationKeyPath = "locations"
let shimmerAnimation = CABasicAnimation(keyPath: animationKeyPath)
shimmerAnimation.fromValue = startLocations
shimmerAnimation.toValue = endLocations
shimmerAnimation.duration = animationDuration
shimmerAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
let animationGroup = CAAnimationGroup()
animationGroup.duration = animationDuration + animationDelay
animationGroup.repeatCount = .infinity
animationGroup.animations = [shimmerAnimation]
// removes animation with same key (if exists) then adds
// the new animation
gradientLayer.removeAnimation(forKey: animationKeyPath)
gradientLayer.add(animationGroup, forKey: animationKeyPath)
}
}class ShimmerButton: UIButton, ShimmerEffect {
override static var layerClass: AnyClass {
return CAGradientLayer.self
}
var gradientLayer: CAGradientLayer {
return layer as! CAGradientLayer
}
var animationDuration: TimeInterval = 3 {
didSet { addShimmerAnimation() }
}
var animationDelay: TimeInterval = 1.5 {
didSet { addShimmerAnimation() }
}
var gradientHighlightRatio: Double = 0.3 {
didSet { addShimmerAnimation() }
}
var gradientTint: UIColor = .black {
didSet { addShimmerAnimation() }
}
var gradientHighlight: UIColor = .white {
didSet { addShimmerAnimation() }
}
override init(frame: CGRect) {
super.init(frame: frame)
gradientLayer.mask = titleLabel?.layer
addShimmerAnimation()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
gradientLayer.mask = titleLabel?.layer
addShimmerAnimation()
}
}let shimmer = ShimmerButton()
shimmer.setTitle("Find new skills", for: .normal)
shimmer.titleLabel?.font = UIFont.systemFont(ofSize: 24, weight: UIFontWeightHeavy)
shimmer.gradientTint = darkBlue
shimmer.gradientHighlight = lightBlue
shimmer.sizeToFit()Context
StackExchange Code Review Q#158336, answer score: 6
Revisions (0)
No revisions yet.