I designed a custom header view that masks an image and draws a border on the bottom edge, which is an arc. It looks like this:
Here's the code for the class:
class HeaderView: UIView
{
private let imageView = UIImageView()
private let dimmerView = UIView()
private let arcShape = CAShapeLayer()
private let maskShape = CAShapeLayer() // Masks the image and the dimmer
private let titleLabel = UILabel()
#IBInspectable var image: UIImage? { didSet { self.imageView.image = self.image } }
#IBInspectable var title: String? { didSet {self.titleLabel.text = self.title} }
#IBInspectable var arcHeight: CGFloat? { didSet {self.setupLayers()} }
// MARK: Initialization
override init(frame: CGRect)
{
super.init(frame:frame)
initMyStuff()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder:aDecoder)
initMyStuff()
}
override func prepareForInterfaceBuilder()
{
backgroundColor = UIColor.clear()
}
internal func initMyStuff()
{
backgroundColor = UIColor.clear()
titleLabel.font = Font.AvenirNext_Bold(24)
titleLabel.text = "TITLE"
titleLabel.textColor = UIColor.white()
titleLabel.layer.shadowColor = UIColor.black().cgColor
titleLabel.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
titleLabel.layer.shadowRadius = 0.0;
titleLabel.layer.shadowOpacity = 1.0;
titleLabel.layer.masksToBounds = false
titleLabel.layer.shouldRasterize = true
imageView.contentMode = UIViewContentMode.scaleAspectFill
addSubview(imageView)
dimmerView.frame = self.bounds
dimmerView.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.6)
addSubview(dimmerView)
addSubview(titleLabel)
// Add the shapes
self.layer.addSublayer(arcShape)
self.layer.addSublayer(maskShape)
self.layer.masksToBounds = true // This seems to be unneeded...test more
// Set constraints
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView .autoPinEdgesToSuperviewEdges()
titleLabel.autoCenterInSuperview()
}
func setupLayers()
{
let aHeight = arcHeight ?? 10
// Create the arc shape
arcShape.path = AppocalypseUI.createHorizontalArcPath(CGPoint(x: 0, y: bounds.size.height), width: bounds.size.width, arcHeight: aHeight)
arcShape.strokeColor = UIColor.white().cgColor
arcShape.lineWidth = 1.0
arcShape.fillColor = UIColor.clear().cgColor
// Create the mask shape
let maskPath = AppocalypseUI.createHorizontalArcPath(CGPoint(x: 0, y: bounds.size.height), width: bounds.size.width, arcHeight: aHeight, closed: true)
maskPath.moveTo(nil, x: bounds.size.width, y: bounds.size.height)
maskPath.addLineTo(nil, x: bounds.size.width, y: 0)
maskPath.addLineTo(nil, x: 0, y: 0)
maskPath.addLineTo(nil, x: 0, y: bounds.size.height)
//let current = CGPathGetCurrentPoint(maskPath);
//print(current)
let mask_Dimmer = CAShapeLayer()
mask_Dimmer.path = maskPath.copy()
maskShape.fillColor = UIColor(red: 0, green: 0, blue: 0, alpha: 1.0).cgColor
maskShape.path = maskPath
// Apply the masks
imageView.layer.mask = maskShape
dimmerView.layer.mask = mask_Dimmer
}
override func layoutSubviews()
{
super.layoutSubviews()
// Let's go old school here...
imageView.frame = self.bounds
dimmerView.frame = self.bounds
setupLayers()
}
}
Something like this will cause it to just snap to the new size without gradually changing its frame:
UIView.animate(withDuration: 1.0)
{
self.headerView.arcHeight = self.new_headerView_arcHeight
self.headerView.frame = self.new_headerView_frame
}
I figure it must have something to do with the fact that I'm using CALayers, but I don't really know enough about what's going on behind the scenes.
EDIT:
Here's the function I use to create the arc path:
class func createHorizontalArcPath(_ startPoint:CGPoint, width:CGFloat, arcHeight:CGFloat, closed:Bool = false) -> CGMutablePath
{
// http://www.raywenderlich.com/33193/core-graphics-tutorial-arcs-and-paths
let arcRect = CGRect(x: startPoint.x, y: startPoint.y-arcHeight, width: width, height: arcHeight)
let arcRadius = (arcRect.size.height/2) + (pow(arcRect.size.width, 2) / (8*arcRect.size.height));
let arcCenter = CGPoint(x: arcRect.origin.x + arcRect.size.width/2, y: arcRect.origin.y + arcRadius);
let angle = acos(arcRect.size.width / (2*arcRadius));
let startAngle = CGFloat(M_PI)+angle // (180 degrees + angle)
let endAngle = CGFloat(M_PI*2)-angle // (360 degrees - angle)
// let startAngle = radians(180) + angle;
// let endAngle = radians(360) - angle;
let path = CGMutablePath();
path.addArc(nil, x: arcCenter.x, y: arcCenter.y, radius: arcRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false);
if(closed == true)
{path.addLineTo(nil, x: startPoint.x, y: startPoint.y);}
return path;
}
BONUS:
Setting the arcHeight property to 0 results in no white line being drawn. Why?
The Path property can't be animated. You have to approach the problem differently. You can draw an arc 'instantly', any arc, so that tells us that we need to handle the animation manually. If you expect the entire draw process to take say 3 seconds, then you might want to split the process to 1000 parts, and call the arc drawing function 1000 times every 0.3 miliseconds to draw the arc again from the beginning to the current point.
self.headerView.arcHeight is not a animatable property. It is only UIView own properties are animatable
you can do something like this
let displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode
let expectedFramesPerSecond = 60
var diff : CGFloat = 0
func update() {
let diffUpdated = self.headerView.arcHeight - self.new_headerView_arcHeight
let done = (fabs(diffUpdated) < 0.1)
if(!done){
self.headerView.arcHeight -= diffUpdated/(expectedFramesPerSecond*0.5)
self.setNeedsDisplay()
}
}
Related
I created a custom UIButton class and it causing the storyboard agent to fail.
I'm Including my extensions cause I really don't know what the problem is.
I tried to debug this view from the storyboard but it sends me straight to assembly code.
I tried to make it a without #IBDesignable, but it still cause a crash.
Also if you tips for improving how I'm writing my class I'll be glad to hear them.
I'll be glad if you can help me
This is my class:
#IBDesignable class customButton: UIButton{
private let imagesPadding: CGFloat = 2
private var ArrowSymbleImageView: UIImageView!
#IBInspectable var iconImageInspectable: UIImage = UIImage(systemName: "globe")!{
willSet {
if (ArrowSymbleImageView != nil) {
ArrowSymbleImageView.image = newValue
}
}
}
#IBInspectable var BackgroundColorInspectable: UIColor = .white {
willSet {
self.backgroundColor = newValue
if (ArrowSymbleImageView != nil) {
if (self.BackgroundColorInspectable.isDarkColor) {
ArrowSymbleImageView.tintColor = .white
}else{
ArrowSymbleImageView.tintColor = .black
}
}
}
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.generalInit()
}
private func generalDeinit() {
ArrowSymbleImageView.removeFromSuperview()
}
private func generalInit() {
self.backgroundColor = self.BackgroundColorInspectable
self.roundCorners(corners: [.bottomLeft], radius: self.width() / 2 * 0.7)
self.dropShadow()
let sizePartFromView: CGFloat = 4
ArrowSymbleImageView = UIImageView(frame: CGRect(x: self.width() / 2 - (self.width() / sizePartFromView / 2),
y: self.height() / 2 - (self.height() / sizePartFromView / 2),
width: self.width() / sizePartFromView,
height: self.height() / sizePartFromView))
ArrowSymbleImageView.image = self.iconImageInspectable
if (self.BackgroundColorInspectable.isDarkColor) {
ArrowSymbleImageView.tintColor = .white
}else{
ArrowSymbleImageView.tintColor = .black
}
ArrowSymbleImageView.contentMode = .scaleAspectFill
self.addSubview(ArrowSymbleImageView)
}
}
internal 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
}
}
internal extension UIView {
func dropShadow(scale: Bool = true, size: CGSize = CGSize(width: -2, height: 2)) {
layer.masksToBounds = false
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.5
layer.shadowOffset = size
layer.shadowRadius = 1
layer.shadowPath = UIBezierPath(rect: bounds).cgPath
layer.shouldRasterize = true
layer.rasterizationScale = scale ? UIScreen.main.scale : 1
}
func dropShadow(color: UIColor, opacity: Float = 0.5, offSet: CGSize, radius: CGFloat = 1, scale: Bool = true) {
layer.masksToBounds = false
layer.shadowColor = color.cgColor
layer.shadowOpacity = opacity
layer.shadowOffset = offSet
layer.shadowRadius = radius
layer.shadowPath = UIBezierPath(rect: self.bounds).cgPath
layer.shouldRasterize = true
layer.rasterizationScale = scale ? UIScreen.main.scale : 1
}
}
internal extension UIColor
{
var isDarkColor: Bool {
var r, g, b, a: CGFloat
(r, g, b, a) = (0, 0, 0, 0)
self.getRed(&r, green: &g, blue: &b, alpha: &a)
let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
return lum < 0.50 ? true : false
}
}
I noticed this:
UIImage(systemName: "globe")!
Is there a reason why you instantiate a custom image this way? Is "globe" an apple provided default image?
You should be really using, if this isn't a default image.
UIImage(named:"globe")!
Dont use Force unwrapping when you do not have confirmation about data always use optional binding and do change following line in code from this UIImage(systemName: "globe")! to UIImage(named:"globe")! .
I have modified Line graph of Minh Nguyen to some extend to show two lines one for systolic and othere for diastolic.
The first image show the how the graph should look like and second image is what I have achieved.
struct PointEntry {
let systolic: Int
let diastolic: Int
let label: String
}
extension PointEntry: Comparable {
static func <(lhs: PointEntry, rhs: PointEntry) -> Bool {
return lhs.systolic < rhs.systolic || lhs.systolic < rhs.systolic
}
static func ==(lhs: PointEntry, rhs: PointEntry) -> Bool {
return lhs.systolic == rhs.systolic && lhs.diastolic == rhs.diastolic
}
}
class LineChart: UIView {
/// gap between each point
let lineGap: CGFloat = 30.0
/// preseved space at top of the chart
let topSpace: CGFloat = 20.0
/// preserved space at bottom of the chart to show labels along the Y axis
let bottomSpace: CGFloat = 40.0
/// The top most horizontal line in the chart will be 10% higher than the highest value in the chart
let topHorizontalLine: CGFloat = 110.0 / 100.0
/// Dot inner Radius
var innerRadius: CGFloat = 8
/// Dot outer Radius
var outerRadius: CGFloat = 12
var dataEntries: [PointEntry]? {
didSet {
self.setNeedsLayout()
}
}
/// Contains the main line which represents the data
private let dataLayer: CALayer = CALayer()
/// Contains dataLayer and gradientLayer
private let mainLayer: CALayer = CALayer()
/// Contains mainLayer and label for each data entry
private let scrollView: UIScrollView = {
let view = UIScrollView()
view.showsVerticalScrollIndicator = false
view.showsHorizontalScrollIndicator = false
return view
}()
/// Contains horizontal lines
private let gridLayer: CALayer = CALayer()
/// An array of CGPoint on dataLayer coordinate system that the main line will go through. These points will be calculated from dataEntries array
private var systolicDataPoint: [CGPoint]?
private var daistolicDataPoint: [CGPoint]?
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
convenience init() {
self.init(frame: CGRect.zero)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
private func setupView() {
mainLayer.addSublayer(dataLayer)
mainLayer.addSublayer(gridLayer)
scrollView.layer.addSublayer(mainLayer)
self.addSubview(scrollView)
}
override func layoutSubviews() {
scrollView.frame = CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height)
if let dataEntries = dataEntries {
scrollView.contentSize = CGSize(width: CGFloat(dataEntries.count) * lineGap + 30, height: self.frame.size.height)
mainLayer.frame = CGRect(x: 0, y: 0, width: CGFloat(dataEntries.count) * lineGap + 30, height: self.frame.size.height)
dataLayer.frame = CGRect(x: 0, y: topSpace, width: mainLayer.frame.width, height: mainLayer.frame.height - topSpace - bottomSpace)
systolicGradientLayer.frame = dataLayer.frame
diastolicGradientLayer.frame = dataLayer.frame
systolicDataPoint = convertDataEntriesToPoints(entries: dataEntries, isSystolic: true)
daistolicDataPoint = convertDataEntriesToPoints(entries: dataEntries, isSystolic: false)
gridLayer.frame = CGRect(x: 0, y: topSpace, width: CGFloat(dataEntries.count) * lineGap + 30, height: mainLayer.frame.height - topSpace - bottomSpace)
clean()
drawHorizontalLines()
drawVerticleLine()
drawChart(for: systolicDataPoint, color: .blue)
drawChart(for: daistolicDataPoint, color: .green)
drawLables()
}
}
/// Convert an array of PointEntry to an array of CGPoint on dataLayer coordinate system
/// - Parameter entries: Arrays of PointEntry
private func convertDataEntriesToPoints(entries: [PointEntry], isSystolic: Bool) -> [CGPoint] {
var result: [CGPoint] = []
// let gridValues: [CGFloat] = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.05]
for (index, value) in entries.enumerated() {
let difference: CGFloat = 0.125 / 30
let userValue: CGFloat = isSystolic ? CGFloat(value.systolic) : CGFloat(value.diastolic)
var height = (userValue - 30.0) * difference
height = (1.0 - height) * gridLayer.frame.size.height
let point = CGPoint(x: CGFloat(index)*lineGap + 40, y: height)
result.append(point)
}
return result
}
/// Draw a zigzag line connecting all points in dataPoints
private func drawChart(for points: [CGPoint]?, color: UIColor) {
if let dataPoints = points, dataPoints.count > 0 {
guard let path = createPath(for: points) else { return }
let lineLayer = CAShapeLayer()
lineLayer.path = path.cgPath
lineLayer.strokeColor = color.cgColor
lineLayer.fillColor = UIColor.clear.cgColor
dataLayer.addSublayer(lineLayer)
}
}
/// Create a zigzag bezier path that connects all points in dataPoints
private func createPath(for points: [CGPoint]?) -> UIBezierPath? {
guard let dataPoints = points, dataPoints.count > 0 else {
return nil
}
let path = UIBezierPath()
path.move(to: dataPoints[0])
for i in 1..<dataPoints.count {
path.addLine(to: dataPoints[i])
}
return path
}
/// Create titles at the bottom for all entries showed in the chart
private func drawLables() {
if let dataEntries = dataEntries,
dataEntries.count > 0 {
for i in 0..<dataEntries.count {
let textLayer = CATextLayer()
textLayer.frame = CGRect(x: lineGap*CGFloat(i) - lineGap/2 + 40, y: mainLayer.frame.size.height - bottomSpace/2 - 8, width: lineGap, height: 16)
textLayer.foregroundColor = UIColor.black.cgColor
textLayer.backgroundColor = UIColor.clear.cgColor
textLayer.alignmentMode = CATextLayerAlignmentMode.center
textLayer.contentsScale = UIScreen.main.scale
textLayer.font = CTFontCreateWithName(UIFont.systemFont(ofSize: 0).fontName as CFString, 0, nil)
textLayer.fontSize = 11
textLayer.string = dataEntries[i].label
mainLayer.addSublayer(textLayer)
}
}
}
/// Create horizontal lines (grid lines) and show the value of each line
private func drawHorizontalLines() {
let gridValues: [CGFloat] = [1.05, 1.0, 0.875, 0.75, 0.625, 0.5, 0.375, 0.25, 0.125]
let gridText = ["", "30", "60", "90", "120", "150", "180", "210", "240"]
for (index, value) in gridValues.enumerated() {
let height = value * gridLayer.frame.size.height
let path = UIBezierPath()
if value == gridValues.first! {
path.move(to: CGPoint(x: 30, y: height))
} else {
path.move(to: CGPoint(x: 28, y: height))
}
path.addLine(to: CGPoint(x: gridLayer.frame.size.width, y: height))
let lineLayer = CAShapeLayer()
lineLayer.path = path.cgPath
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.strokeColor = UIColor.black.cgColor
if value != gridValues.first! {
lineLayer.lineDashPattern = [4, 4]
}
lineLayer.lineWidth = 0.5
gridLayer.addSublayer(lineLayer)
let textLayer = CATextLayer()
textLayer.frame = CGRect(x: 4, y: height-8, width: 50, height: 16)
textLayer.foregroundColor = UIColor.black.cgColor
textLayer.backgroundColor = UIColor.clear.cgColor
textLayer.contentsScale = UIScreen.main.scale
textLayer.font = CTFontCreateWithName(UIFont.systemFont(ofSize: 0).fontName as CFString, 0, nil)
textLayer.fontSize = 12
textLayer.string = gridText[index]
gridLayer.addSublayer(textLayer)
}
}
private func drawVerticleLine() {
let height = gridLayer.frame.size.height * 1.05
let path = UIBezierPath()
path.move(to: CGPoint(x: 30, y: 0))
path.addLine(to: CGPoint(x: 30, y: height))
let lineLayer = CAShapeLayer()
lineLayer.path = path.cgPath
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.strokeColor = UIColor.black.cgColor
lineLayer.lineWidth = 0.5
gridLayer.addSublayer(lineLayer)
}
private func clean() {
mainLayer.sublayers?.forEach({
if $0 is CATextLayer {
$0.removeFromSuperlayer()
}
})
dataLayer.sublayers?.forEach({$0.removeFromSuperlayer()})
gridLayer.sublayers?.forEach({$0.removeFromSuperlayer()})
}
}
How can I add shadow to lines like shown in the first image and add simple line drawing animation to the Graph?
I am trying to make an animated donut view that when given a value between 0 and 100 it will animate round the view up to that number. I have this working fine but want to fade the color from one to another, then another on the way around. Currently, when I add my gradient it goes from left to right and not around the circumference of the donut view.
class CircleScoreView: UIView {
private let outerCircleLayer = CAShapeLayer()
private let outerCircleGradientLayer = CAGradientLayer()
private let outerCircleLineWidth: CGFloat = 5
override init(frame: CGRect) {
super.init(frame: .zero)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
buildLayers()
}
/// Value must be within 0...100 range
func setScore(_ value: Int, animated: Bool = false) {
if value != 0 {
let clampedValue: CGFloat = CGFloat(value.clamped(to: 0...100)) / 100
if !animated {
outerCircleLayer.strokeEnd = clampedValue
} else {
let outerCircleAnimation = CABasicAnimation(keyPath: "strokeEnd")
outerCircleAnimation.duration = 1.0
outerCircleAnimation.fromValue = 0
outerCircleAnimation.toValue = clampedValue
outerCircleAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
outerCircleLayer.strokeEnd = clampedValue
outerCircleLayer.add(outerCircleAnimation, forKey: "outerCircleAnimation")
}
outerCircleGradientLayer.colors = [Constant.Palette.CircleScoreView.startValue.cgColor,
Constant.Palette.CircleScoreView.middleValue.cgColor,
Constant.Palette.CircleScoreView.endValue.cgColor]
}
}
private func buildLayers() {
// Outer background circle
let arcCenter = CGPoint(x: frame.size.width / 2, y: frame.size.height / 2)
let startAngle = CGFloat(-0.5 * Double.pi)
let endAngle = CGFloat(1.5 * Double.pi)
let circlePath = UIBezierPath(arcCenter: arcCenter,
radius: (frame.size.width - outerCircleLineWidth) / 2,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)
// Outer circle
setupOuterCircle(outerCirclePath: circlePath)
}
private func setupOuterCircle(outerCirclePath: UIBezierPath) {
outerCircleLayer.path = outerCirclePath.cgPath
outerCircleLayer.fillColor = UIColor.clear.cgColor
outerCircleLayer.strokeColor = UIColor.black.cgColor
outerCircleLayer.lineWidth = outerCircleLineWidth
outerCircleLayer.lineCap = CAShapeLayerLineCap.round
outerCircleGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
outerCircleGradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
outerCircleGradientLayer.frame = bounds
outerCircleGradientLayer.mask = outerCircleLayer
layer.addSublayer(outerCircleGradientLayer)
}
}
I am going for something like this but the color isn't one block but gradients around the donut view from one color to the next.
If you imported AngleGradientLayer into your project then all you should need to do is change:
private let outerCircleGradientLayer = CAGradientLayer() to
private let outerCircleGradientLayer = AngleGradientLayer()
I've written simple animations for drawing rectangles in lines, we can treat them as a bars.
Each bar is one shape layer which has a path which animates ( size change and fill color change ).
#IBDesignable final class BarView: UIView {
lazy var pathAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
let red = UIColor(red: 249/255, green: 26/255, blue: 26/255, alpha: 1)
let orange = UIColor(red: 1, green: 167/255, blue: 463/255, alpha: 1)
let green = UIColor(red: 106/255, green: 239/255, blue: 47/255, alpha: 1)
lazy var backgroundColorAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "fillColor")
animation.duration = 1
animation.fromValue = red.cgColor
animation.byValue = orange.cgColor
animation.toValue = green.cgColor
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
#IBInspectable var spaceBetweenBars: CGFloat = 10
var numberOfBars: Int = 5
let data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
override func awakeFromNib() {
super.awakeFromNib()
initSublayers()
}
override func layoutSubviews() {
super.layoutSubviews()
setupLayers()
}
func setupLayers() {
let width = bounds.width - (spaceBetweenBars * CGFloat(numberOfBars + 1)) // There is n + 1 spaces between bars.
let barWidth: CGFloat = width / CGFloat(numberOfBars)
let scalePoint: CGFloat = bounds.height / 10.0 // 10.0 - 10 points is max
guard let sublayers = layer.sublayers as? [CAShapeLayer] else { return }
for i in 0...numberOfBars - 1 {
let barHeight: CGFloat = scalePoint * data[i]
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
var xPos: CGFloat!
if i == 0 {
xPos = spaceBetweenBars
} else if i == numberOfBars - 1 {
xPos = bounds.width - (barWidth + spaceBetweenBars)
} else {
xPos = barWidth * CGFloat(i) + spaceBetweenBars * CGFloat(i) + spaceBetweenBars
}
let startPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: 0)).cgPath
let endPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: -barHeight)).cgPath
sublayers[i].path = startPath
pathAnimation.toValue = endPath
sublayers[i].removeAllAnimations()
sublayers[i].add(pathAnimation, forKey: "path")
sublayers[i].add(backgroundColorAnimation, forKey: "backgroundColor")
}
}
func initSublayers() {
for _ in 1...numberOfBars {
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
}
}
}
The size ( height ) of bar depends of the data array, each sublayers has a different height. Based on this data I've crated a scale.
PathAnimation is changing height of the bars.
BackgroundColorAnimation is changing the collors of the path. It starts from red one, goes through the orange and finish at green.
My goal is to connect backgroundColorAnimation with data array as well as it's connected with pathAnimation.
Ex. When in data array is going to be value 1.0 then the bar going to be animate only to the red color which is a derivated from a base red color which is declared as a global variable. If the value in the data array going to be ex. 4.5 then the color animation will stop close to the delcared orange color, the 5.0 limit going to be this orange color or color close to this. Value closer to 10 going to be green.
How could I connect these conditions with animation properties fromValue, byValue, toValue. Is it an algorithm for that ? Any ideas ?
You have several problems.
You're setting fillMode and isRemovedOnCompletion. This tells me, to be blunt, that you don't understand Core Animation. You need to watch WWDC 2011 Session 421: Core Animation Essentials.
You're adding more layers every time layoutSubviews is called, but not doing anything with them.
You're adding animation every time layoutSubviews runs. Do you really want to re-animate the bars when the double-height “in-call” status bar appears or disappears, or on an interface rotation? It's probably better to have a separate animateBars() method, and call it from your view controller's viewDidAppear method.
You seem to think byValue means “go through this value on the way from fromValue to toValue”, but that's not what it means. byValue is ignored in your case, because you're setting fromValue and toValue. The effects of byValue are explained in Setting Interpolation Values.
If you want to interpolate between colors, it's best to use a hue-based color space, but I believe Core Animation uses an RGB color space. So you should use a keyframe animation to specify intermediate colors that you calculate by interpolating in a hue-based color space.
Here's a rewrite of BarView that fixes all these problems:
#IBDesignable final class BarView: UIView {
#IBInspectable var spaceBetweenBars: CGFloat = 10
var data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
var maxDatum = CGFloat(10)
func animateBars() {
guard window != nil else { return }
let bounds = self.bounds
var flatteningTransform = CGAffineTransform.identity.translatedBy(x: 0, y: bounds.size.height).scaledBy(x: 1, y: 0.001)
let duration: CFTimeInterval = 1
let frames = Int((duration * 60.0).rounded(.awayFromZero))
for (datum, barLayer) in zip(data, barLayers) {
let t = datum / maxDatum
if let path = barLayer.path {
let path0 = path.copy(using: &flatteningTransform)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.duration = 1
pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
pathAnimation.fromValue = path0
barLayer.add(pathAnimation, forKey: pathAnimation.keyPath)
let colors = gradient.colors(from: 0, to: t, count: frames).map({ $0.cgColor })
let colorAnimation = CAKeyframeAnimation(keyPath: "fillColor")
colorAnimation.timingFunction = pathAnimation.timingFunction
colorAnimation.duration = duration
colorAnimation.values = colors
barLayer.add(colorAnimation, forKey: colorAnimation.keyPath)
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
createOrDestroyBarLayers()
let bounds = self.bounds
let barSpacing = (bounds.size.width - spaceBetweenBars) / CGFloat(data.count)
let barWidth = barSpacing - spaceBetweenBars
for ((offset: i, element: datum), barLayer) in zip(data.enumerated(), barLayers) {
let t = datum / maxDatum
let barHeight = t * bounds.size.height
barLayer.frame = bounds
let rect = CGRect(x: spaceBetweenBars + CGFloat(i) * barSpacing, y: bounds.size.height, width: barWidth, height: -barHeight)
barLayer.path = CGPath(rect: rect, transform: nil)
barLayer.fillColor = gradient.color(at: t).cgColor
}
}
private let gradient = Gradient(startColor: .red, endColor: .green)
private var barLayers = [CAShapeLayer]()
private func createOrDestroyBarLayers() {
while barLayers.count < data.count {
barLayers.append(CAShapeLayer())
layer.addSublayer(barLayers.last!)
}
while barLayers.count > data.count {
barLayers.removeLast().removeFromSuperlayer()
}
}
}
private extension UIColor {
var hsba: [CGFloat] {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
return [hue, saturation, brightness, alpha]
}
}
private struct Gradient {
init(startColor: UIColor, endColor: UIColor) {
self.startColor = startColor
self.startHsba = startColor.hsba
self.endColor = endColor
self.endHsba = endColor.hsba
}
let startColor: UIColor
let endColor: UIColor
let startHsba: [CGFloat]
let endHsba: [CGFloat]
func color(at t: CGFloat) -> UIColor {
let out = zip(startHsba, endHsba).map { $0 * (1.0 - t) + $1 * t }
return UIColor(hue: out[0], saturation: out[1], brightness: out[2], alpha: out[3])
}
func colors(from t0: CGFloat, to t1: CGFloat, count: Int) -> [UIColor] {
var colors = [UIColor]()
colors.reserveCapacity(count)
for i in 0 ..< count {
let s = CGFloat(i) / CGFloat(count - 1)
let t = t0 * (1 - s) + t1 * s
colors.append(color(at: t))
}
return colors
}
}
Result:
I am trying to have an animation in a view controller in which the circle rotates with animation. The circle should rotate until a process completed like a gif below. I have implemented the circle animation but couldn't reach to the point what I want to achieve.
import UIKit
class ViewController: UIViewController {
var circle : Circle?;
override func viewDidLoad() {
super.viewDidLoad();
view.backgroundColor = UIColor.white;
setupViews();
}
func setupViews(){
circle = Circle(frame: self.view.frame);
view.addSubview(circle!);
circle?.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true;
circle?.topAnchor.constraint(equalTo: view.topAnchor).isActive = true;
circle?.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true;
circle?.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true;
}
}
class Circle : UIView{
override init(frame: CGRect) {
super.init(frame: frame);
self.backgroundColor = .blue;
self.translatesAutoresizingMaskIntoConstraints = false;
setupCircle();
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
let gradientLayer = CAGradientLayer();
func setupCircle(){
layer.addSublayer(shapeLayer);
let circlePath = UIBezierPath(arcCenter: CGPoint(x: self.frame.width / 2 - 50, y: self.frame.height / 2 - 50), radius: 50, startAngle: CGFloat(Double.pi * (0 / 4)), endAngle: CGFloat(Double.pi * 2), clockwise: true);
shapeLayer.path = circlePath.cgPath;
let group = CAAnimationGroup()
group.animations = [animateStrokeEnd, animateOpacity]
group.duration = 0.8
group.repeatCount = HUGE // repeat forver
shapeLayer.add(group, forKey: nil)
}
let shapeLayer: CAShapeLayer = {
let layer = CAShapeLayer();
layer.strokeColor = UIColor.white.cgColor;
layer.lineWidth = 5;
layer.fillColor = UIColor.clear.cgColor;
layer.strokeStart = 0
layer.strokeEnd = 1;
return layer;
}();
let animateOpacity : CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "opacity");
animation.fromValue = 0;
animation.toValue = 0.8;
animation.byValue = 0.01;
animation.repeatCount = Float.infinity;
return animation
}();
let animateStrokeEnd: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "strokeEnd");
animation.fromValue = 0;
animation.repeatCount = Float.infinity;
animation.toValue = 1;
return animation;
}();
}
I am using strokeEnd animation to implement the animation. And opacity to animate the color. But when the circle reaches 360 degree, its makes a lag before starting a new circle.
Does anybody know how to remove this effect and get smooth animation?
The above code produces this animation
But i want to achieve this animation
Also the stroke colour is different from the original animation. Can we achieve this animation using the CABasicAnimation?
Rather than trying to animate the actual drawing, just draw the view once and then animate it.
Here is a custom PadlockView and a custom CircleView which mimic the animation you showed. To use it, add the code below to your project. Add a UIView to your Storyboard, change its class to PadlockView, and make an #IBOutlet to it (called padlock perhaps). When you want the view to animate, set padlock.circle.isAnimating = true. To stop animating, set padlock.circle.isAnimating = false.
CircleView.swift
// This UIView extension was borrowed from #keval's answer:
// https://stackoverflow.com/a/41160100/1630618
extension UIView {
func rotate360Degrees(duration: CFTimeInterval = 3) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = CGFloat.pi * 2
rotateAnimation.isRemovedOnCompletion = false
rotateAnimation.duration = duration
rotateAnimation.repeatCount = Float.infinity
self.layer.add(rotateAnimation, forKey: nil)
}
}
class CircleView: UIView {
var foregroundColor = UIColor.white
var lineWidth: CGFloat = 3.0
var isAnimating = false {
didSet {
if isAnimating {
self.isHidden = false
self.rotate360Degrees(duration: 1.0)
} else {
self.isHidden = true
self.layer.removeAllAnimations()
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
func setup() {
self.isHidden = true
self.backgroundColor = .clear
}
override func draw(_ rect: CGRect) {
let width = bounds.width
let height = bounds.height
let radius = (min(width, height) - lineWidth) / 2.0
var currentPoint = CGPoint(x: width / 2.0 + radius, y: height / 2.0)
var priorAngle = CGFloat(360)
for angle in stride(from: CGFloat(360), through: 0, by: -2) {
let path = UIBezierPath()
path.lineWidth = lineWidth
path.move(to: currentPoint)
currentPoint = CGPoint(x: width / 2.0 + cos(angle * .pi / 180.0) * radius, y: height / 2.0 + sin(angle * .pi / 180.0) * radius)
path.addArc(withCenter: CGPoint(x: width / 2.0, y: height / 2.0), radius: radius, startAngle: priorAngle * .pi / 180.0 , endAngle: angle * .pi / 180.0, clockwise: false)
priorAngle = angle
foregroundColor.withAlphaComponent(angle/360.0).setStroke()
path.stroke()
}
}
}
PadlockView.swift
class PadlockView: UIView {
var circle: CircleView!
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
func setup() {
self.backgroundColor = .clear
circle = CircleView()
circle.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(circle)
circle.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
circle.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
circle.widthAnchor.constraint(equalTo: self.widthAnchor).isActive = true
circle.heightAnchor.constraint(equalTo: self.heightAnchor).isActive = true
}
override func draw(_ rect: CGRect) {
let width = bounds.width
let height = bounds.height
let lockwidth = width / 3
let lockheight = height / 4
let boltwidth = lockwidth * 2 / 3
UIColor.white.setStroke()
let path = UIBezierPath()
path.move(to: CGPoint(x: (width - lockwidth) / 2, y: height / 2))
path.addLine(to: CGPoint(x: (width + lockwidth) / 2, y: height / 2))
path.addLine(to: CGPoint(x: (width + lockwidth) / 2, y: height / 2 + lockheight))
path.addLine(to: CGPoint(x: (width - lockwidth) / 2, y: height / 2 + lockheight))
path.close()
path.move(to: CGPoint(x: (width - boltwidth) / 2, y: height / 2))
path.addLine(to: CGPoint(x: (width - boltwidth) / 2, y: height / 2 - boltwidth / 4))
path.addArc(withCenter: CGPoint(x: width/2, y: height / 2 - boltwidth / 4), radius: boltwidth / 2, startAngle: .pi, endAngle: 0, clockwise: true)
path.lineWidth = 2.0
path.stroke()
}
}
Note: Continuous animation code courtesy of this answer.
Here is a demo that I setup with the following code in my ViewController:
#IBOutlet weak var padlock: PadlockView!
#IBAction func startStop(_ sender: UIButton) {
if sender.currentTitle == "Start" {
sender.setTitle("Stop", for: .normal)
padlock.circle.isAnimating = true
} else {
sender.setTitle("Start", for: .normal)
padlock.circle.isAnimating = false
}
}