Related
I'm fairly new to ios programming
I try to draw some arcs for my circular progress bar in UIView.draw(rect:) method.
I'm using UIGraphicsGetCurrentContext to get the context and add arc path and draw in the method of custom UIView called MyProgressView
So I made an object of my custom view and it worked fine
But if I make a couple of more objects, they don't work
UIView.draw(rect:) is called every time whenever it needs to be.
the first one is drawing properly and the rest are not
class MyProgressView: UIView {
var startAngle: Double = 0.0
var endAngle: Double = 0.0 {
didSet{
self.setNeedsDisplay()
}
}
func radian(_ degree: Double) -> Double {
return (degree - 90) * Double.pi / 180.0
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setLineWidth(10)
context.setStrokeColor(UIColor.white.cgColor)
context.addArc(center: self.center, radius: 30, startAngle: radian(startAngle), endAngle: radian(endAngle), clockwise: false)
context.strokePath()
}
}
I put self.setNeedsDisplay() in didSet of a variable called endAngle
and inside of view controller's viewDidLoad,
let progressBar = MyProgressView()
progressBar.frame = CGRect(x: 0, y: 0, width: view.frame.width / 2, height:view.frame.height / 2)
progressBar.backgroundColor = .systemMint
progressBar.startAngle = 0
progressBar.endAngle = 180
view.addSubview(progressBar)
and it looks fine
but if I add two more, they don't work
let progressBar2 = MyProgressView()
progressBar2.frame = CGRect(x: 150, y: 0, width: view.frame.width / 2, height: view.frame.height / 2)
progressBar2.backgroundColor = .systemPink
progressBar2.startAngle = 90
progressBar2.endAngle = 270
view.addSubview(progressBar2)
let progressBar3 = MyProgressView()
progressBar3.frame = CGRect(x: 0, y: 150, width: view.frame.width / 2, height: view.frame.height / 2)
progressBar3.backgroundColor = .systemIndigo
progressBar3.startAngle = 90
progressBar3.endAngle = 270
view.addSubview(progressBar3)
Can anybody explain what's missing here?
I made it and run in playground, iOS version is 15.4
Thanks
You have 2 problems
1- The method that converts to radians isn't correct
2- You need to use a zero based center point
class MyProgressView: UIView {
var startAngle: Double = 0.0
var endAngle: Double = 0.0
func radian(_ number: Double) -> CGFloat {
return CGFloat(number * .pi / 180)
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setLineWidth(10)
context.setStrokeColor(UIColor.white.cgColor)
context.addArc(center: CGPoint(x:rect.size.width / 2, y:rect.size.height / 2), radius: 30, startAngle: radian(startAngle), endAngle: radian(endAngle), clockwise: false)
context.strokePath()
}
}
Full demo
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let progressBar = MyProgressView()
progressBar.frame = CGRect(x: 0, y: 0, width: view.frame.width / 2, height:view.frame.height / 2)
progressBar.backgroundColor = .systemMint
progressBar.startAngle = 0
progressBar.endAngle = 180
view.addSubview(progressBar)
let progressBar2 = MyProgressView()
progressBar2.frame = CGRect(x: 150, y: 0, width: view.frame.width / 2, height: view.frame.height / 2)
progressBar2.backgroundColor = .systemPink
progressBar2.startAngle = 90
progressBar2.endAngle = 270
view.addSubview(progressBar2)
let progressBar3 = MyProgressView()
progressBar3.frame = CGRect(x: 0, y: 300, width: view.frame.width / 2, height: view.frame.height / 2)
progressBar3.backgroundColor = .systemIndigo
progressBar3.startAngle = 90
progressBar3.endAngle = 270
view.addSubview(progressBar3)
}
}
class MyProgressView: UIView {
var startAngle: Double = 0.0
var endAngle: Double = 0.0
func radian(_ number: Double) -> CGFloat {
return CGFloat(number * .pi / 180)
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setLineWidth(10)
context.setStrokeColor(UIColor.white.cgColor)
context.addArc(center: CGPoint(x:rect.size.width / 2, y:rect.size.height / 2), radius: 30, startAngle: radian(startAngle), endAngle: radian(endAngle), clockwise: false)
context.strokePath()
}
}
Bit stumped here.
First of all, here is what's going on:
As you can see, my ball is not following the curved quarter-circle path exactly, but vaguely.
Here is the code creating the quarter-circle (p.s. - my container view is 294 units tall and wide):
let startAngle = CGFloat(Double.pi * 2) // top of circle
let endAngle = startAngle + 2 * Double.pi * 0.25
view.layoutIfNeeded()
smallCircleView.parentVC = self
smallCircleView.layer.cornerRadius = 45/2
let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: containerView.frame.self.width, startAngle: startAngle, endAngle: endAngle, clockwise: true)
And here is the code shifting the ball around:
func shiftSmallCircleView(newX : CGFloat){
smallCircleViewLeadingConstraint.constant = newX
let angle = (newX/containerView.frame.self.width)*90 + 180
let y = containerView.frame.size.width * cos((Double.pi * 2 * angle) / 360)
smallCircleViewBottomConstraint.constant = y + containerView.frame.origin.y
}
Since I'm using the cos function, should the ball's path be identical to the original quarter-circle path? How can they be similar but not identical?
Edit:
New outcome with updated code:
let angle = (distanceDelta/containerView.frame.self.width) * -90.0
containerView.transform = CGAffineTransform.init(rotationAngle: angle * Double.pi/180)
Most recent edit:
let angle = (distanceDelta/pathContainerView.frame.self.width) * .pi / -180.0
containerView.transform = CGAffineTransform.init(rotationAngle: angle)
All code:
class SmallCircleView : UIView {
var parentVC : ViewController!
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first as? UITouch {
let point = touch.location(in: self)
}
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first as? UITouch {
let point = touch.location(in: self.superview)=
parentVC.shiftSmallCircleView(distanceDelta: point.x)=
}
}
}
class ViewController: UIViewController {
#IBOutlet var containerView : UIView!
#IBOutlet var pathContainerView : UIView!
#IBOutlet var smallCircleView : SmallCircleView!
#IBOutlet var smallCircleViewLeadingConstraint : NSLayoutConstraint!
#IBOutlet var smallCircleViewBottomConstraint : NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let startAngle = CGFloat(Double.pi * 2) // top of circle
let endAngle = startAngle + 2 * Double.pi * 0.25
view.layoutIfNeeded()
smallCircleView.parentVC = self
smallCircleView.layer.cornerRadius = 45/2
let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: pathContainerView.frame.self.width, startAngle: startAngle, endAngle: endAngle, clockwise: true)
let shapeLayer = CAShapeLayer()
// The Bezier path that we made needs to be converted to
// a CGPath before it can be used on a layer.
shapeLayer.path = circlePath.cgPath
// apply other properties related to the path
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = UIColor.white.cgColor
shapeLayer.lineWidth = 1.0
shapeLayer.position = CGPoint(x: 0, y: 0)
// add the new layer to our custom view
pathContainerView.layer.addSublayer(shapeLayer)
containerView.bringSubviewToFront(smallCircleView)
}
func shiftSmallCircleView(distanceDelta : CGFloat){
let degrees = min(1, (distanceDelta/pathContainerView.frame.size.width)) * -90
containerView.transform = CGAffineTransform.init(rotationAngle: degrees * M_PI/180)
}
}
I’m on my phone right now and so I can’t provide the code but there is a much much easier way to do this. Don’t bother trying to work out what coordinates the ball needs to be at. Just place the ball into a rectangular view with the centre of this view being at the centre of your circle and the ball being on the path. (Make the container view invisible).
Now… rotate the container view.
That’s it.
Because the ball is a child of the view it will be moved as part of the rotation. And the movement will follow a circle centred around the point of rotation. Which is the centre of the container view.
Example
I made a quick example to show what I mean. In essence... cheat. Don't actually do the hard maths to work out where the ball will be. Use methods to make it look the same in an easier way...
Here is my storyboard...
And the code...
class ViewController: UIViewController {
#IBOutlet weak var containerView: UIView!
#IBOutlet weak var circleView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
circleView.layer.cornerRadius = 20
}
#IBAction func sliderChanged(_ sender: UISlider) {
let rotationAngle = sender.value * .pi / 180
containerView.transform = CGAffineTransform(rotationAngle: CGFloat(rotationAngle))
}
}
And an animation...
And if you make the container background clear...
To answer your original question...
I haven't double-checked your math, but this is another method of positioning your "small circle" view.
Using these two "helper" extensions:
extension CGPoint {
static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
return CGPoint(x: x, y: y)
}
}
extension CGFloat {
var degreesToRadians: Self { self * .pi / 180 }
var radiansToDegrees: Self { self * 180 / .pi }
}
We can find the point on the arc for a given angle like this:
let arcCenter: CGPoint = .zero
let radius: CGFloat = 250
let degree: CGFloat = 45
let p = CGPoint.pointOnCircle(center: arcCenter, radius: radius, angle: degree.degreesToRadians)
We can then move the "ball" to that point:
circleView.center = p
To get the circle view to "roll along the inside" of the arc, we use the same center point, but decrease the radius of the arc by the radius of the circle (half the width of the view).
If you want to use that approach (rather than rotating a view with the circle in the corner), here is some example code.
Start with our extensions, an enum, and a "small circle view":
extension CGPoint {
static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
return CGPoint(x: x, y: y)
}
}
extension CGFloat {
var degreesToRadians: Self { self * .pi / 180 }
var radiansToDegrees: Self { self * 180 / .pi }
}
enum FollowType: Int {
case inside, center, outside
}
class SmallCircleView : UIView {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.size.height / 2.0
}
}
Next, a UIView subclass that will handle drawing the arc, adding the circle subview, and it will use a UIViewPropertyAnimator with key frames to make it interactive:
class FollowArcView: UIView {
public var circleColor: UIColor = .red {
didSet {
circleView.backgroundColor = circleColor
}
}
public var arcColor: UIColor = .blue { didSet { setNeedsLayout() } }
public var arcLineWidth: CGFloat = 1 { didSet { setNeedsLayout() } }
public var arcInset: CGFloat = 0 { didSet { setNeedsLayout() } }
public var circleRadius: CGFloat = 25 { didSet { setNeedsLayout() } }
public var followType: FollowType = .inside { didSet { setNeedsLayout() } }
public var fractionComplete: CGFloat = 0 {
didSet {
if animator != nil {
animator.fractionComplete = fractionComplete
}
}
}
private let circleView = SmallCircleView()
private let arcLayer = CAShapeLayer()
private var animator: UIViewPropertyAnimator!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
arcLayer.fillColor = UIColor.clear.cgColor
layer.addSublayer(arcLayer)
circleView.frame = CGRect(x: 0, y: 0, width: circleRadius * 2.0, height: circleRadius * 2.0)
circleView.backgroundColor = circleColor
addSubview(circleView)
}
override func layoutSubviews() {
super.layoutSubviews()
var curFraction: CGFloat = 0
// we may be changing properties (such as arc color, inset, etc)
// after we've moved the circleView, so
// save the current .fractionComplete
if animator != nil {
curFraction = animator.fractionComplete
animator.stopAnimation(true)
}
// these properties can be changed after initial view setup
arcLayer.lineWidth = arcLineWidth
arcLayer.strokeColor = arcColor.cgColor
circleView.frame.size = CGSize(width: circleRadius * 2.0, height: circleRadius * 2.0)
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
let pth = UIBezierPath(arcCenter: arcCenter, radius: arcRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
arcLayer.path = pth.cgPath
let p = CGPoint.pointOnCircle(center: arcCenter, radius: followRadius, angle: CGFloat(90).degreesToRadians)
circleView.center = p
// the animator will take the current position of the circleView
// as Frame 0, so we need to let UIKit update the circleView's position
// before setting up the animator
DispatchQueue.main.async {
self.setupAnim()
self.animator.fractionComplete = curFraction
}
}
private func setupAnim() {
if animator != nil {
animator.stopAnimation(true)
}
// starting point
var startDegrees: CGFloat = 90
// ending point
let endDegrees: CGFloat = 0
// we'll be using percentages
let numSteps: CGFloat = 100
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
animator = UIViewPropertyAnimator(duration: 0.3, curve: .linear)
animator.addAnimations {
UIView.animateKeyframes(withDuration: 0.1, delay: 0.0, animations: {
let stepDegrees: Double = (startDegrees - endDegrees) / Double(numSteps)
for i in 1...Int(numSteps) {
// decrement degrees by step value
startDegrees -= stepDegrees
// get point on discPathRadius circle
let p = CGPoint.pointOnCircle(center: arcCenter, radius: followRadius, angle: startDegrees.degreesToRadians)
// duration is 1 divided by number of steps
let duration = 1.0 / Double(numSteps)
// start time for this frame is duration * this step
let startTime = duration * Double(i)
// add the keyframe
UIView.addKeyframe(withRelativeStartTime: startTime, relativeDuration: duration) {
self.circleView.center = p
}
}
})
}
// start and immediately pause the animation
animator.startAnimation()
animator.pauseAnimation()
}
}
and an example controller class:
class FollowArcVC: UIViewController {
let followArcView = FollowArcView()
override func viewDidLoad() {
super.viewDidLoad()
let slider = UISlider()
let typeControl = UISegmentedControl(items: ["Inside", "Center", "Outside"])
[followArcView, typeControl, slider].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
followArcView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
followArcView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
followArcView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
// 1:1 ratio (square)
followArcView.heightAnchor.constraint(equalTo: followArcView.widthAnchor),
typeControl.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
typeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
typeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
slider.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
typeControl.selectedSegmentIndex = 0
typeControl.addTarget(self, action: #selector(typeChanged(_:)), for: .valueChanged)
slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
// if we want to see the view frame
//followArcView.backgroundColor = .yellow
}
#objc func typeChanged(_ sender: UISegmentedControl) -> Void {
switch sender.selectedSegmentIndex {
case 0:
followArcView.followType = .inside
case 1:
followArcView.followType = .center
case 2:
followArcView.followType = .outside
default:
()
}
}
#objc func sliderChanged(_ sender: Any?) {
guard let sldr = sender as? UISlider else { return }
followArcView.fractionComplete = CGFloat(sldr.value)
}
}
The result:
Edit
After playing around a bit, this is another way to interactively follow the path -- it uses layer path animation, and avoids the need to manually calculate keyframe positions.
Works with the same sample view controller as above - just replace the FollowArcView class:
class FollowArcView: UIView {
public var circleColor: UIColor = .red {
didSet {
circleView.backgroundColor = circleColor
}
}
public var arcColor: UIColor = .blue {
didSet {
arcLayer.strokeColor = arcColor.cgColor
}
}
public var arcLineWidth: CGFloat = 1 {
didSet {
arcLayer.lineWidth = arcLineWidth
}
}
public var arcInset: CGFloat = 0 { didSet { setNeedsLayout() } }
public var circleRadius: CGFloat = 25 { didSet { setNeedsLayout() } }
public var followType: FollowType = .inside { didSet { setNeedsLayout() } }
public var fractionComplete: CGFloat = 0 {
didSet {
circleView.layer.timeOffset = CFTimeInterval(fractionComplete)
}
}
private let circleView = SmallCircleView()
private let arcLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
arcLayer.fillColor = UIColor.clear.cgColor
arcLayer.lineWidth = arcLineWidth
arcLayer.strokeColor = arcColor.cgColor
layer.addSublayer(arcLayer)
circleView.frame = CGRect(x: 0, y: 0, width: circleRadius * 2.0, height: circleRadius * 2.0)
circleView.backgroundColor = circleColor
addSubview(circleView)
}
override func layoutSubviews() {
super.layoutSubviews()
circleView.frame.size = CGSize(width: circleRadius * 2.0, height: circleRadius * 2.0)
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let pth = UIBezierPath(arcCenter: arcCenter, radius: arcRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
arcLayer.path = pth.cgPath
self.setupAnim()
}
private func setupAnim() {
let arcCenter = CGPoint(x: arcInset, y: arcInset)
let arcRadius = bounds.width - (arcInset * 2.0)
let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
let pth = UIBezierPath(arcCenter: arcCenter, radius: followRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
animation.duration = 1
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
animation.path = pth.cgPath
circleView.layer.speed = 0
circleView.layer.timeOffset = 0
circleView.layer.add(animation, forKey: "PathAnim")
DispatchQueue.main.async {
self.circleView.layer.timeOffset = self.fractionComplete
}
}
}
I have created a circle using a CAShapeLayer. Now I want to add text to the control but I am not quite sure on how to do so (so it looks good).
I have the following code:
import Foundation
import UIKit
class Gauge : UIView
{
private var shapeLayer = CAShapeLayer()
private var maskingLayer = CAShapeLayer()
private var gradientLayer = CAGradientLayer()
private var textLayers: [CATextLayer] = []
private var mValue: CGFloat = 0.0
private var mSegments = 9
private let textHeight: CGFloat = 24.0
// MARK: Properties
var lineWidth: CGFloat = 32.0
var min: CGFloat = 0.0
var max: CGFloat = 100.0
var segments: Int
{
get { return self.mSegments - 1 }
set
{
self.mSegments = newValue + 1
self.commonInit()
}
}
var progress: CGFloat
{
get
{
let diff = abs(self.min) + self.max
return self.value / diff
}
}
var segmentSize: CGFloat = 270.0
{
didSet
{
self.value = 0.0
self.commonInit()
}
}
var value: CGFloat
{
get { return self.mValue }
set
{
if self.mValue == newValue { return }
if newValue < 0.0
{
self.mValue = 0.0
}
else if newValue > self.max
{
self.mValue = self.max
}
else
{
self.mValue = newValue
}
self.maskingLayer.strokeStart = 0.0
self.maskingLayer.strokeEnd = 0.5
}
}
override init(frame: CGRect)
{
super.init(frame: frame)
self.commonInit()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
self.commonInit()
}
fileprivate func commonInit()
{
self.value = 50
self.determineLineWidth()
self.initLayers()
self.initDataLayers()
self.initTextLayers()
}
override func layoutSubviews()
{
super.layoutSubviews()
self.commonInit()
}
fileprivate func initTextLayers()
{
for textLayer in self.textLayers
{
textLayer.removeFromSuperlayer()
}
let fontSize: CGFloat = self.getFontSize()
for i in 0 ... self.segments
{
let orientation = CGFloat(i) * (1.0 / CGFloat(self.segments))
let span = self.max + abs(self.min)
let step = span / CGFloat(self.segments)
let value = CGFloat(i) * step
let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
let width = Utilities.measure(Int(value).description, .zero, font)
let point = self.getLabelPosition(orientation, width)
let layer = CATextLayer()
layer.contentsScale = UIScreen.main.scale
layer.font = font
layer.foregroundColor = UIColor.black.cgColor
layer.fontSize = fontSize
layer.string = Int(value).description
layer.alignmentMode = .center
layer.frame = CGRect(origin: point, size: .init(width: 48.0, height: self.textHeight))
self.textLayers.append(layer)
self.layer.addSublayer(layer)
}
}
fileprivate func gaugeFont() -> UIFont
{
let valueFontSize = self.getFontSize()
return UIFont.boldSystemFont(ofSize: valueFontSize)
}
fileprivate func getFontSize() -> CGFloat
{
if self.bounds.height < 128.0
{
return 10.0
}
else if self.bounds.height < 256.0
{
return 14.0
}
else
{
return 18.0
}
}
fileprivate func initDataLayers()
{
self.maskingLayer.removeFromSuperlayer()
let fillPath = self.createPath()
self.maskingLayer.frame = self.bounds
self.maskingLayer.path = fillPath.cgPath
self.maskingLayer.lineCap = .round
self.maskingLayer.fillColor = UIColor.clear.cgColor
self.maskingLayer.strokeColor = UIColor.black.cgColor
self.maskingLayer.lineWidth = self.lineWidth / 2.0
self.maskingLayer.position = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
self.layer.addSublayer(self.maskingLayer)
}
fileprivate func calculateAngle(_ value: CGFloat) -> CGFloat
{
let diff = abs(self.min) + self.max
return value / diff
}
fileprivate func getLabelPosition(_ progress: CGFloat, _ width: CGFloat) -> CGPoint
{
let size = Swift.min(self.bounds.width - self.lineWidth, self.bounds.height - self.lineWidth)
let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
let alpha = (180.0 - self.segmentSize) / 2.0
let radius = size / 2.0 - self.lineWidth - width
let cx = center.x
let cy = center.y
let angle = self.segmentSize * progress
let x2 = self.deg2rad(180.0 + alpha + angle)
let outerX = cx + (radius + self.lineWidth / 2.0) * CGFloat(cos(x2))
let outerY = cy + (radius + self.lineWidth / 2.0) * CGFloat(sin(x2))
return CGPoint(x: outerX, y: outerY)
}
fileprivate func initLayers()
{
self.shapeLayer.removeFromSuperlayer()
let path = self.createPath()
self.shapeLayer = CAShapeLayer()
self.shapeLayer.frame = self.bounds
self.shapeLayer.path = path.cgPath
self.shapeLayer.strokeColor = UIColor.lightGray.cgColor
self.shapeLayer.fillColor = nil
self.shapeLayer.lineWidth = self.lineWidth / 2.0
self.shapeLayer.lineCap = .round
self.layer.addSublayer(self.shapeLayer)
}
fileprivate func createPath() -> UIBezierPath
{
let size = Swift.min(self.frame.width - self.lineWidth / 2, self.frame.height - self.lineWidth)
let center = CGPoint(x: self.frame.width / 2.0, y: self.frame.height / 2.0)
let alpha = (180.0 - self.segmentSize) / 2.0
let path = UIBezierPath(arcCenter: center, radius: size / 2.0, startAngle: self.deg2rad(180.0 + alpha), endAngle: self.deg2rad(360.0 - alpha), clockwise: true)
return path
}
fileprivate func determineLineWidth()
{
if self.bounds.height < 192.0
{
self.lineWidth = 20.0
}
else if self.bounds.height < 320
{
self.lineWidth = 32.0
}
else
{
self.lineWidth = 40.0
}
}
fileprivate func deg2rad(_ number: CGFloat) -> CGFloat
{
return number * .pi / 180
}
}
The result looks like this:
But I want the text to be positioned perfectly like this:
I tried adding various offsets manually, but when the control gets resized, it started to look bad again. Is there some kind of formula which I can use to calculate the exact position?
It looks like getLabelPosition returns a point that should be used as the centre of the text but you're passing it to the frame so it's used as the upper left point.
You need to offset the point by hals the size of the label to get the origin.
let size = CGSize(width: 48.0, height: self.textHeight)
var origin = point
origin.x -= size.width / 2
origin.y -= size.height / 2
layer.frame = CGRect(origin: origin, size: size)
I'm trying to make a line graph with no libraries, but I just cmd+c, cmd+v all the code. Yes, I know that I shouldn't do so, but I don't have much time
So I did everything with help of this - https://medium.com/#tstenerson/lets-make-a-line-chart-in-swift-3-5e819e6c1a00
Also added a view to the view controller and called it LineChart
But on line 42 I get an error Thread 1: ECX_BAD_ACCESS (code = EXC_I386_GPFLT)
lineChart.deltaX = 20
I don't know how to fix it
I coded only in ViewController.swift, here it is:
import UIKit
extension String {
func size(withSystemFontSize pointSize: CGFloat) -> CGSize {
return (self as NSString).size(attributes: [NSFontAttributeName: UIFont.systemFont(ofSize: pointSize)])
}
}
extension CGPoint {
func adding(x: CGFloat) -> CGPoint { return CGPoint(x: self.x + x, y: self.y) }
func adding(y: CGFloat) -> CGPoint { return CGPoint(x: self.x, y: self.y + y) }
}
class ViewController: UIViewController {
#IBOutlet var lineChart: LineChart!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let f: (CGFloat) -> CGPoint = {
let noiseY = (CGFloat(arc4random_uniform(2)) * 2 - 1) * CGFloat(arc4random_uniform(4))
let noiseX = (CGFloat(arc4random_uniform(2)) * 2 - 1) * CGFloat(arc4random_uniform(4))
let b: CGFloat = 5
let y = 2 * $0 + b + noiseY
return CGPoint(x: $0 + noiseX, y: y)
}
let xs = [Int](1..<20)
let points = xs.map({f(CGFloat($0 * 10))})
lineChart.deltaX = 20
lineChart.deltaY = 30
lineChart.plot(points)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
class LineChart: UIView {
let lineLayer = CAShapeLayer()
let circlesLayer = CAShapeLayer()
var chartTransform: CGAffineTransform?
#IBInspectable var lineColor: UIColor = UIColor.green {
didSet {
lineLayer.strokeColor = lineColor.cgColor
}
}
#IBInspectable var lineWidth: CGFloat = 1
#IBInspectable var showPoints: Bool = true { // show the circles on each data point
didSet {
circlesLayer.isHidden = !showPoints
}
}
#IBInspectable var circleColor: UIColor = UIColor.green {
didSet {
circlesLayer.fillColor = circleColor.cgColor
}
}
#IBInspectable var circleSizeMultiplier: CGFloat = 3
#IBInspectable var axisColor: UIColor = UIColor.white
#IBInspectable var showInnerLines: Bool = true
#IBInspectable var labelFontSize: CGFloat = 10
var axisLineWidth: CGFloat = 1
var deltaX: CGFloat = 10 // The change between each tick on the x axis
var deltaY: CGFloat = 10 // and y axis
var xMax: CGFloat = 100
var yMax: CGFloat = 100
var xMin: CGFloat = 0
var yMin: CGFloat = 0
var data: [CGPoint]?
override init(frame: CGRect) {
super.init(frame: frame)
combinedInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
combinedInit()
}
func combinedInit() {
layer.addSublayer(lineLayer)
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.strokeColor = lineColor.cgColor
layer.addSublayer(circlesLayer)
circlesLayer.fillColor = circleColor.cgColor
layer.borderWidth = 1
layer.borderColor = axisColor.cgColor
}
override func layoutSubviews() {
super.layoutSubviews()
lineLayer.frame = bounds
circlesLayer.frame = bounds
if let d = data{
setTransform(minX: xMin, maxX: xMax, minY: yMin, maxY: yMax)
plot(d)
}
}
func setAxisRange(forPoints points: [CGPoint]) {
guard !points.isEmpty else { return }
let xs = points.map() { $0.x }
let ys = points.map() { $0.y }
// МИНИМАЛЬНЫЕ И МАКСИМАЛЬНЫЕ ЗНАЧЕНИЯ
xMax = ceil(xs.max()! / deltaX) * deltaX
yMax = ceil(ys.max()! / deltaY) * deltaY
xMin = 0
yMin = 0
setTransform(minX: xMin, maxX: xMax, minY: yMin, maxY: yMax)
}
func setAxisRange(xMin: CGFloat, xMax: CGFloat, yMin: CGFloat, yMax: CGFloat) {
self.xMin = xMin
self.xMax = xMax
self.yMin = yMin
self.yMax = yMax
setTransform(minX: xMin, maxX: xMax, minY: yMin, maxY: yMax)
}
func setTransform(minX: CGFloat, maxX: CGFloat, minY: CGFloat, maxY: CGFloat) {
let xLabelSize = "\(Int(maxX))".size(withSystemFontSize: labelFontSize)
let yLabelSize = "\(Int(maxY))".size(withSystemFontSize: labelFontSize)
let xOffset = xLabelSize.height + 2
let yOffset = yLabelSize.width + 5
let xScale = (bounds.width - yOffset - xLabelSize.width/2 - 2)/(maxX - minX)
let yScale = (bounds.height - xOffset - yLabelSize.height/2 - 2)/(maxY - minY)
chartTransform = CGAffineTransform(a: xScale, b: 0, c: 0, d: -yScale, tx: yOffset, ty: bounds.height - xOffset)
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
// draw rect comes with a drawing context, so lets grab it.
// Also, if there is not yet a chart transform, we will bail on performing any other drawing.
// I like guard statements for this because it's kind of like a bouncer to a bar.
// If you don't have your transform yet, you can't enter drawAxes.
guard let context = UIGraphicsGetCurrentContext(), let t = chartTransform else { return }
drawAxes(in: context, usingTransform: t)
}
func drawAxes(in context: CGContext, usingTransform t: CGAffineTransform) {
context.saveGState()
// Make two paths, one for thick lines, one for thin.
let thickerLines = CGMutablePath()
let thinnerLines = CGMutablePath()
// The two line chart axes.
let xAxisPoints = [CGPoint(x: xMin, y: 0), CGPoint(x: xMax, y: 0)]
let yAxisPoints = [CGPoint(x: 0, y: yMin), CGPoint(x: 0, y: yMax)]
// Add each to thicker lines but apply our transform too.
thickerLines.addLines(between: xAxisPoints, transform: t)
thickerLines.addLines(between: yAxisPoints, transform: t)
// Next we go from xMin to xMax by deltaX using stride
for x in stride(from: xMin, through: xMax, by: deltaX) {
// Tick points are the points for the ticks on each axis.
// We check showInnerLines first to see if we are drawing small ticks or full lines.
// Yip for new guys: `let a = someBool ? b : c` is called a ternary operator.
// In English it means "let a = b if somebool is true, or c if it is false."
let tickPoints = showInnerLines ?
[CGPoint(x: x, y: yMin).applying(t), CGPoint(x: x, y: yMax).applying(t)] :
[CGPoint(x: x, y: 0).applying(t), CGPoint(x: x, y: 0).applying(t).adding(y: -5)]
thinnerLines.addLines(between: tickPoints)
if x != xMin { // draw the tick label (it is too buy if you draw it at the origin for both x & y
let label = "\(Int(x))" as NSString // Int to get rid of the decimal, NSString to draw
let labelSize = "\(Int(x))".size(withSystemFontSize: labelFontSize)
let labelDrawPoint = CGPoint(x: x, y: 0).applying(t)
.adding(x: -labelSize.width/2)
.adding(y: 1)
label.draw(at: labelDrawPoint,
withAttributes:
[NSFontAttributeName: UIFont.systemFont(ofSize: labelFontSize),
NSForegroundColorAttributeName: axisColor])
}
}
// Repeat for y.
for y in stride(from: yMin, through: yMax, by: deltaY) {
let tickPoints = showInnerLines ?
[CGPoint(x: xMin, y: y).applying(t), CGPoint(x: xMax, y: y).applying(t)] :
[CGPoint(x: 0, y: y).applying(t), CGPoint(x: 0, y: y).applying(t).adding(x: 5)]
thinnerLines.addLines(between: tickPoints)
if y != yMin {
let label = "\(Int(y))" as NSString
let labelSize = "\(Int(y))".size(withSystemFontSize: labelFontSize)
let labelDrawPoint = CGPoint(x: 0, y: y).applying(t)
.adding(x: -labelSize.width - 1)
.adding(y: -labelSize.height/2)
label.draw(at: labelDrawPoint,
withAttributes:
[NSFontAttributeName: UIFont.systemFont(ofSize: labelFontSize),
NSForegroundColorAttributeName: axisColor])
}
}
// Finally set stroke color & line width then stroke thick lines, repeat for thin.
context.setStrokeColor(axisColor.cgColor)
context.setLineWidth(axisLineWidth)
context.addPath(thickerLines)
context.strokePath()
context.setStrokeColor(axisColor.withAlphaComponent(0.5).cgColor)
context.setLineWidth(axisLineWidth/2)
context.addPath(thinnerLines)
context.strokePath()
context.restoreGState()
// Whenever you change a graphics context you should save it prior and restore it after.
// If we were using a context other than draw(rect) we would have to also end the graphics context.
}
func plot(_ points: [CGPoint]) {
lineLayer.path = nil
circlesLayer.path = nil
data = nil
guard !points.isEmpty else { return }
self.data = points
if self.chartTransform == nil {
setAxisRange(forPoints: points)
}
let linePath = CGMutablePath()
linePath.addLines(between: points, transform: chartTransform!)
lineLayer.path = linePath
if showPoints {
circlesLayer.path = circles(atPoints: points, withTransform: chartTransform!)
}
}
func circles(atPoints points: [CGPoint], withTransform t: CGAffineTransform) -> CGPath {
let path = CGMutablePath()
let radius = lineLayer.lineWidth * circleSizeMultiplier/2
for i in points {
let p = i.applying(t)
let rect = CGRect(x: p.x - radius, y: p.y - radius, width: radius * 2, height: radius * 2)
path.addEllipse(in: rect)
}
return path
}
} // <- I didn't close the LineChart class up top, closing it now
}
In storyboard remove reference outlet link to 'lineChart' and try this:
import UIKit
extension String {
func size(withSystemFontSize pointSize: CGFloat) -> CGSize {
return (self as NSString).size(attributes: [NSFontAttributeName: UIFont.systemFont(ofSize: pointSize)])
}
}
extension CGPoint {
func adding(x: CGFloat) -> CGPoint { return CGPoint(x: self.x + x, y: self.y) }
func adding(y: CGFloat) -> CGPoint { return CGPoint(x: self.x, y: self.y + y) }
}
class ViewController: UIViewController {
// #IBOutlet var lineChart: LineChart! ////////////REMOVED THIS
var lineChart = LineChart(frame: CGRect.zero) ////////////ADDED THIS
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let f: (CGFloat) -> CGPoint = {
let noiseY = (CGFloat(arc4random_uniform(2)) * 2 - 1) * CGFloat(arc4random_uniform(4))
let noiseX = (CGFloat(arc4random_uniform(2)) * 2 - 1) * CGFloat(arc4random_uniform(4))
let b: CGFloat = 5
let y = 2 * $0 + b + noiseY
return CGPoint(x: $0 + noiseX, y: y)
}
let xs = [Int](1..<20)
let points = xs.map({f(CGFloat($0 * 10))})
////////////ADDED THIS
self.lineChart.frame = CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height)
self.view.addSubview(self.lineChart)
lineChart.deltaX = 20
lineChart.deltaY = 30
lineChart.plot(points)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
class LineChart: UIView {
let lineLayer = CAShapeLayer()
let circlesLayer = CAShapeLayer()
var chartTransform: CGAffineTransform?
#IBInspectable var lineColor: UIColor = UIColor.green {
didSet {
lineLayer.strokeColor = lineColor.cgColor
}
}
#IBInspectable var lineWidth: CGFloat = 1
#IBInspectable var showPoints: Bool = true { // show the circles on each data point
didSet {
circlesLayer.isHidden = !showPoints
}
}
#IBInspectable var circleColor: UIColor = UIColor.green {
didSet {
circlesLayer.fillColor = circleColor.cgColor
}
}
#IBInspectable var circleSizeMultiplier: CGFloat = 3
#IBInspectable var axisColor: UIColor = UIColor.white
#IBInspectable var showInnerLines: Bool = true
#IBInspectable var labelFontSize: CGFloat = 10
var axisLineWidth: CGFloat = 1
var deltaX: CGFloat = 10 // The change between each tick on the x axis
var deltaY: CGFloat = 10 // and y axis
var xMax: CGFloat = 100
var yMax: CGFloat = 100
var xMin: CGFloat = 0
var yMin: CGFloat = 0
var data: [CGPoint]?
override init(frame: CGRect) {
super.init(frame: frame)
combinedInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
combinedInit()
}
func combinedInit() {
layer.addSublayer(lineLayer)
lineLayer.fillColor = UIColor.clear.cgColor
lineLayer.strokeColor = lineColor.cgColor
layer.addSublayer(circlesLayer)
circlesLayer.fillColor = circleColor.cgColor
layer.borderWidth = 1
layer.borderColor = axisColor.cgColor
}
override func layoutSubviews() {
super.layoutSubviews()
lineLayer.frame = bounds
circlesLayer.frame = bounds
if let d = data{
setTransform(minX: xMin, maxX: xMax, minY: yMin, maxY: yMax)
plot(d)
}
}
func setAxisRange(forPoints points: [CGPoint]) {
guard !points.isEmpty else { return }
let xs = points.map() { $0.x }
let ys = points.map() { $0.y }
// МИНИМАЛЬНЫЕ И МАКСИМАЛЬНЫЕ ЗНАЧЕНИЯ
xMax = ceil(xs.max()! / deltaX) * deltaX
yMax = ceil(ys.max()! / deltaY) * deltaY
xMin = 0
yMin = 0
setTransform(minX: xMin, maxX: xMax, minY: yMin, maxY: yMax)
}
func setAxisRange(xMin: CGFloat, xMax: CGFloat, yMin: CGFloat, yMax: CGFloat) {
self.xMin = xMin
self.xMax = xMax
self.yMin = yMin
self.yMax = yMax
setTransform(minX: xMin, maxX: xMax, minY: yMin, maxY: yMax)
}
func setTransform(minX: CGFloat, maxX: CGFloat, minY: CGFloat, maxY: CGFloat) {
let xLabelSize = "\(Int(maxX))".size(withSystemFontSize: labelFontSize)
let yLabelSize = "\(Int(maxY))".size(withSystemFontSize: labelFontSize)
let xOffset = xLabelSize.height + 2
let yOffset = yLabelSize.width + 5
let xScale = (bounds.width - yOffset - xLabelSize.width/2 - 2)/(maxX - minX)
let yScale = (bounds.height - xOffset - yLabelSize.height/2 - 2)/(maxY - minY)
chartTransform = CGAffineTransform(a: xScale, b: 0, c: 0, d: -yScale, tx: yOffset, ty: bounds.height - xOffset)
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
// draw rect comes with a drawing context, so lets grab it.
// Also, if there is not yet a chart transform, we will bail on performing any other drawing.
// I like guard statements for this because it's kind of like a bouncer to a bar.
// If you don't have your transform yet, you can't enter drawAxes.
guard let context = UIGraphicsGetCurrentContext(), let t = chartTransform else { return }
drawAxes(in: context, usingTransform: t)
}
func drawAxes(in context: CGContext, usingTransform t: CGAffineTransform) {
context.saveGState()
// make two paths, one for thick lines, one for thin
let thickerLines = CGMutablePath()
let thinnerLines = CGMutablePath()
// the two line chart axes
let xAxisPoints = [CGPoint(x: xMin, y: 0), CGPoint(x: xMax, y: 0)]
let yAxisPoints = [CGPoint(x: 0, y: yMin), CGPoint(x: 0, y: yMax)]
// add each to thicker lines but apply our transform too.
thickerLines.addLines(between: xAxisPoints, transform: t)
thickerLines.addLines(between: yAxisPoints, transform: t)
// next we go from xMin to xMax by deltaX using stride
for x in stride(from: xMin, through: xMax, by: deltaX) {
// tick points are the points for the ticks on each axis
// we check showInnerLines first to see if we are drawing small ticks or full lines
// tip for new guys: `let a = someBool ? b : c` is called a ternary operator
// in english it means "let a = b if somebool is true, or c if it is false."
let tickPoints = showInnerLines ?
[CGPoint(x: x, y: yMin).applying(t), CGPoint(x: x, y: yMax).applying(t)] :
[CGPoint(x: x, y: 0).applying(t), CGPoint(x: x, y: 0).applying(t).adding(y: -5)]
thinnerLines.addLines(between: tickPoints)
if x != xMin { // draw the tick label (it is too buy if you draw it at the origin for both x & y
let label = "\(Int(x))" as NSString // Int to get rid of the decimal, NSString to draw
let labelSize = "\(Int(x))".size(withSystemFontSize: labelFontSize)
let labelDrawPoint = CGPoint(x: x, y: 0).applying(t)
.adding(x: -labelSize.width/2)
.adding(y: 1)
label.draw(at: labelDrawPoint,
withAttributes:
[NSFontAttributeName: UIFont.systemFont(ofSize: labelFontSize),
NSForegroundColorAttributeName: axisColor])
}
}
// repeat for y
for y in stride(from: yMin, through: yMax, by: deltaY) {
let tickPoints = showInnerLines ?
[CGPoint(x: xMin, y: y).applying(t), CGPoint(x: xMax, y: y).applying(t)] :
[CGPoint(x: 0, y: y).applying(t), CGPoint(x: 0, y: y).applying(t).adding(x: 5)]
thinnerLines.addLines(between: tickPoints)
if y != yMin {
let label = "\(Int(y))" as NSString
let labelSize = "\(Int(y))".size(withSystemFontSize: labelFontSize)
let labelDrawPoint = CGPoint(x: 0, y: y).applying(t)
.adding(x: -labelSize.width - 1)
.adding(y: -labelSize.height/2)
label.draw(at: labelDrawPoint,
withAttributes:
[NSFontAttributeName: UIFont.systemFont(ofSize: labelFontSize),
NSForegroundColorAttributeName: axisColor])
}
}
// finally set stroke color & line width then stroke thick lines, repeat for thin
context.setStrokeColor(axisColor.cgColor)
context.setLineWidth(axisLineWidth)
context.addPath(thickerLines)
context.strokePath()
context.setStrokeColor(axisColor.withAlphaComponent(0.5).cgColor)
context.setLineWidth(axisLineWidth/2)
context.addPath(thinnerLines)
context.strokePath()
context.restoreGState()
// whenever you change a graphics context you should save it prior and restore it after
// if we were using a context other than draw(rect) we would have to also end the graphics context
}
func plot(_ points: [CGPoint]) {
lineLayer.path = nil
circlesLayer.path = nil
data = nil
guard !points.isEmpty else { return }
self.data = points
if self.chartTransform == nil {
setAxisRange(forPoints: points)
}
let linePath = CGMutablePath()
linePath.addLines(between: points, transform: chartTransform!)
lineLayer.path = linePath
if showPoints {
circlesLayer.path = circles(atPoints: points, withTransform: chartTransform!)
}
}
func circles(atPoints points: [CGPoint], withTransform t: CGAffineTransform) -> CGPath {
let path = CGMutablePath()
let radius = lineLayer.lineWidth * circleSizeMultiplier/2
for i in points {
let p = i.applying(t)
let rect = CGRect(x: p.x - radius, y: p.y - radius, width: radius * 2, height: radius * 2)
path.addEllipse(in: rect)
}
return path
}
} // <- I didn't close the LineChart class up top, closing it now
}
I am working on this animation where a number will be received every second and progress bar has to fill or go down based on the double value.
I have created the views and have added all the views in the UIStackView. Also made the outlet collection for all the views. (sorting them by the tag and making them round rect).
I can loop the views and change their background color but trying to see if there is a better way to do it. Any suggestions?
Thanks
So how you are doing it is fine. Here would be two different ways. The first with Core Graphics. You may want to update methods and even make the color gradient in the sublayer.
1st Way
import UIKit
class Indicator: UIView {
var padding : CGFloat = 5.0
var minimumSpace : CGFloat = 4.0
var indicators : CGFloat = 40
var indicatorColor : UIColor = UIColor.lightGray
var filledIndicatorColor = UIColor.blue
var currentProgress = 0.0
var radiusFactor : CGFloat = 0.25
var fillReversed = false
override init(frame: CGRect) {
super.init(frame: frame)
setUp(animated: false)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setUp(animated: false)
backgroundColor = UIColor.green
}
func updateProgress(progress:Double, animated:Bool) {
currentProgress = progress
setUp(animated: animated)
}
private func setUp(animated:Bool){
// internal space
let neededPadding = (indicators - 1) * minimumSpace
//calculate height and width minus padding and space since vertical
let height = (bounds.height - neededPadding - (padding * 2.0)) / indicators
let width = bounds.width - padding * 2.0
if animated == true{
let trans = CATransition()
trans.type = kCATransitionFade
trans.duration = 0.5
self.layer.add(trans, forKey: nil)
}
layer.sublayers?.removeAll()
for i in 0...Int(indicators - 1.0){
let indicatorLayer = CALayer()
indicatorLayer.frame = CGRect(x: padding, y: CGFloat(i) * height + padding + (minimumSpace * CGFloat(i)), width: width, height: height)
//haha i don't understand my logic below but it works hahaha
// i know it has to go backwards
if fillReversed{
if CGFloat(1 - currentProgress) * self.bounds.height < indicatorLayer.frame.origin.y{
indicatorLayer.backgroundColor = filledIndicatorColor.cgColor
}else{
indicatorLayer.backgroundColor = indicatorColor.cgColor
}
}else{
if CGFloat(currentProgress) * self.bounds.height > indicatorLayer.frame.origin.y{
indicatorLayer.backgroundColor = indicatorColor.cgColor
}else{
indicatorLayer.backgroundColor = filledIndicatorColor.cgColor
}
}
indicatorLayer.cornerRadius = indicatorLayer.frame.height * radiusFactor
indicatorLayer.masksToBounds = true
self.layer.addSublayer(indicatorLayer)
}
}
//handle rotation
override func layoutSubviews() {
super.layoutSubviews()
setUp(animated: false)
}
}
The second way is using CAShapeLayer and the benefit is that the progress will get a natural animation.
import UIKit
class Indicator: UIView {
var padding : CGFloat = 5.0
var minimumSpace : CGFloat = 4.0
var indicators : CGFloat = 40
var indicatorColor : UIColor = UIColor.lightGray
var filledIndicatorColor = UIColor.blue
var currentProgress = 0.0
var radiusFactor : CGFloat = 0.25
private var progressLayer : CALayer?
private var shapeHoles : CAShapeLayer?
override init(frame: CGRect) {
super.init(frame: frame)
transparentDotsAndProgress()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
transparentDotsAndProgress()
}
func updateProgress(progress:Double) {
if progress <= 1 && progress >= 0{
currentProgress = progress
transparentDotsAndProgress()
}
}
//handle rotation
override func layoutSubviews() {
super.layoutSubviews()
transparentDotsAndProgress()
}
func transparentDotsAndProgress(){
self.layer.masksToBounds = true
let neededPadding = (indicators - 1) * minimumSpace
//calculate height and width minus padding and space since vertical
let height = (bounds.height - neededPadding - (padding * 2.0)) / indicators
let width = bounds.width - padding * 2.0
let path = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: self.bounds.width, height: self.bounds.height), cornerRadius: 0)
path.usesEvenOddFillRule = true
for i in 0...Int(indicators - 1.0){
let circlePath = UIBezierPath(roundedRect: CGRect(x: padding, y: CGFloat(i) * height + padding + (minimumSpace * CGFloat(i)), width: width, height: height), cornerRadius: height * radiusFactor)
path.append(circlePath)
}
if progressLayer == nil{
progressLayer = CALayer()
progressLayer?.backgroundColor = filledIndicatorColor.cgColor
self.layer.addSublayer(progressLayer!)
}
progressLayer?.frame = CGRect(x: 0, y: -self.bounds.height - padding + CGFloat(currentProgress) * self.bounds.height, width: bounds.width, height: bounds.height)
self.shapeHoles?.removeFromSuperlayer()
shapeHoles = CAShapeLayer()
shapeHoles?.path = path.cgPath
shapeHoles?.fillRule = kCAFillRuleEvenOdd
shapeHoles?.fillColor = UIColor.white.cgColor
shapeHoles?.strokeColor = UIColor.clear.cgColor
self.layer.backgroundColor = indicatorColor.cgColor
self.layer.addSublayer(shapeHoles!)
}
}
Both of these ways should work but the advantage of the CAShapeLayer is you get a natural animation.
I'm a firm believer in learning through solving organic problems and slowly building my global knowledge on a subject. So I'm afraid I don't have any good tutorials for you.
Here is an example that will jump start you, though.
// For participating in Simulator's "slow animations"
#_silgen_name("UIAnimationDragCoefficient") func UIAnimationDragCoefficient() -> Float
import UIKit
#IBDesignable
class VerticalProgessView: UIControl {
#IBInspectable
var numberOfSegments: UInt = 0
#IBInspectable
var verticalSegmentGap: CGFloat = 4.0
#IBInspectable
var outerColor: UIColor = UIColor(red: 33, green: 133, blue: 109)
#IBInspectable
var unfilledColor: UIColor = UIColor(red: 61, green: 202, blue: 169)
#IBInspectable
var filledColor: UIColor = UIColor.white
private var _progress: Float = 0.25
#IBInspectable
open var progress: Float {
get {
return _progress
}
set {
self.setProgress(newValue, animated: false)
}
}
let progressLayer = CALayer()
let maskLayer = CAShapeLayer()
var skipLayoutSubviews = false
open func setProgress(_ progress: Float, animated: Bool) {
if progress < 0 {
_progress = 0
} else if progress > 1.0 {
_progress = 1
} else {
// Clamp the percentage to discreet values
let discreetPercentageDistance: Float = 1.0 / 28.0
let nearestProgress = discreetPercentageDistance * round(progress/discreetPercentageDistance)
_progress = nearestProgress
}
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
self?.skipLayoutSubviews = false
}
if !animated {
CATransaction.setDisableActions(true)
} else {
CATransaction.setAnimationDuration(0.25 * Double(UIAnimationDragCoefficient()))
}
let properties = progressLayerProperties()
progressLayer.bounds = properties.bounds
progressLayer.position = properties.position
skipLayoutSubviews = true
CATransaction.commit() // This triggers layoutSubviews
}
override func prepareForInterfaceBuilder() {
awakeFromNib()
}
override func awakeFromNib() {
super.awakeFromNib()
self.backgroundColor = UIColor.clear
self.layer.backgroundColor = unfilledColor.cgColor
// Initialize and add the progressLayer
let properties = progressLayerProperties()
progressLayer.bounds = properties.bounds
progressLayer.position = properties.position
progressLayer.backgroundColor = filledColor.cgColor
self.layer.addSublayer(progressLayer)
// Initialize and add the maskLayer (it has the holes)
maskLayer.frame = self.layer.bounds
maskLayer.fillColor = outerColor.cgColor
maskLayer.fillRule = kCAFillRuleEvenOdd
maskLayer.path = maskPath(for: maskLayer.bounds)
self.layer.addSublayer(maskLayer)
// Layer hierarchy
// self.maskLayer
// self.progressLayer
// self.layer
}
override func layoutSubviews() {
super.layoutSubviews()
if skipLayoutSubviews {
// Crude but effective, not fool proof though
skipLayoutSubviews = false
return
}
let timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
// Doesn't work for 180° rotations
let duration = UIApplication.shared.statusBarOrientationAnimationDuration * Double(UIAnimationDragCoefficient())
CATransaction.begin()
CATransaction.setAnimationTimingFunction(timingFunction)
CATransaction.setAnimationDuration(duration)
let properties = progressLayerProperties()
progressLayer.bounds = properties.bounds
progressLayer.position = properties.position
let size = self.bounds.size
let anchorPoint = CGPoint(x: 0.5, y: 1.0)
maskLayer.anchorPoint = anchorPoint
maskLayer.bounds = CGRect(origin: CGPoint.zero, size: size)
maskLayer.position = CGPoint(x: size.width * anchorPoint.x, y: size.height * anchorPoint.y)
// Animate the segments
let pathChangeAnimation = CAKeyframeAnimation(keyPath: "path")
let finalPath = maskPath(for: maskLayer.bounds)
pathChangeAnimation.values = [maskLayer.path!, finalPath]
pathChangeAnimation.keyTimes = [0, 1]
pathChangeAnimation.timingFunction = timingFunction
pathChangeAnimation.duration = duration
pathChangeAnimation.isRemovedOnCompletion = true
maskLayer.add(pathChangeAnimation, forKey: "pathAnimation")
CATransaction.setCompletionBlock {
// CAAnimation's don't actually change the value
self.maskLayer.path = finalPath
}
CATransaction.commit()
}
// Provides a path that will mask out all the holes to show self.layer and the progressLayer behind
private func maskPath(for rect: CGRect) -> CGPath {
let horizontalSegmentGap: CGFloat = 5.0
let segmentWidth = rect.width - horizontalSegmentGap * 2
let segmentHeight = rect.height/CGFloat(numberOfSegments) - verticalSegmentGap + verticalSegmentGap/CGFloat(numberOfSegments)
let segmentSize = CGSize(width: segmentWidth, height: segmentHeight)
let segmentRect = CGRect(x: horizontalSegmentGap, y: 0, width: segmentSize.width, height: segmentSize.height)
let path = CGMutablePath()
for i in 0..<numberOfSegments {
// Literally, just move it down by the y value here
// this simplifies the math of calculating the starting points and what not
let transform = CGAffineTransform.identity.translatedBy(x: 0, y: (segmentSize.height + verticalSegmentGap) * CGFloat(i))
let segmentPath = UIBezierPath(roundedRect: segmentRect, cornerRadius: segmentSize.height / 2)
segmentPath.apply(transform)
path.addPath(segmentPath.cgPath)
}
// Without the outerPath, we'll end up with a bunch of squircles instead of a bunch of holes
let outerPath = CGPath(rect: rect, transform: nil)
path.addPath(outerPath)
return path
}
/// Provides the current and correct bounds and position for the progressLayer
private func progressLayerProperties() -> (bounds: CGRect, position: CGPoint) {
let frame = self.bounds
let height = frame.height * CGFloat(progress)
let y = frame.height * CGFloat(1 - progress)
let width = frame.width
let anchorPoint = maskLayer.anchorPoint
let bounds = CGRect(x: 0, y: 0, width: width, height: height)
let position = CGPoint(x: 0 + width * anchorPoint.x, y: y + height * anchorPoint.x)
return (bounds: bounds, position: position)
}
// TODO: Implement functions to further mimic UIProgressView
}
extension UIColor {
convenience init(red: Int, green: Int, blue: Int) {
self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: 1)
}
}
Using in a storyboard
Enjoy the magic