iOS - Draw a view with gradient background - ios

I have attached a rough sketch. The lines are deformed as the sketch was drawn manually just to illustrate the concept.
As seen in the sketch, I have a list of points that has to be drawn on the view automatically (it is irregular shape), clockwise with some delay (0.1 seconds) to see the progress visually.
Sketch would illustrate 70% approximate completion of the shape.
As the view draws, I have to maintain the background gradient. As seen in the sketch, Start point and the Current point are never connected directly. The colour must be filled only between Start point -> Centre point -> Current point.
Coming to the gradient part, there are two colours. Turquoise colour concentrated at the centre and the colour gets lighter to white as it moves away from the centre point.
How would I implement this in iOS? I am able to draw the black lines in the shape, but, I am unable to fill the colour. And gradient, I have no idea at all.

To begin with a path needs to be generated. You probably already have this but you have not provided any code for it although you mentioned "I am able to draw the black lines in the shape". So to begin with the code...
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { $1.radius > $0.radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: $0) }
path.close()
return path
}
Here points are expected to be around 0.0. They are distributed so that they try to fill maximum space depending on given frame and they are centered on it. Nothing special, just basic math.
After a path is generated you may either use shape-layer approach or draw-rect approach. I will use the draw-rect:
You may subclass an UIView and override a method func draw(_ rect: CGRect). This method will be called whenever a view needs a display and you should NEVER call this method directly. So in order to redraw the view you simply call setNeedsDisplay on the view. Starting with code:
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
}
Nothing very special. The context is grabbed for drawing the gradient and for clipping (later). Other than that the path is created using the previous method and then passed to two rendering methods.
Starting with the line things get very simple:
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
there should most likely be a property for color but I just hardcoded it.
As for gradient things do get a bit more scary:
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { $0.cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
When drawing a radial gradient you need to clip it with your path. This is done by calling path.addClip() which uses a "fill" approach on your path and applies it to current context. This means that everything you draw after this call will be clipped to this path and outside of it nothing will be drawn. But you DO want to draw outside of it later (the line does) and you need to reset the clip. This is done by saving and restoring state on your current context calling saveGState and restoreGState. These calls are push-pop so for every "save" there should be a "restore". And you can nest this procedure (as it will be done when applying a progress).
Using just this code you should already be able to draw your full shape (as in with 100% progress). To give my test example I use it all in code like this:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
}
}
Adding a progress now only needs additional clipping. We would like to draw only within a certain angle. This should be straight forward by now:
Another property is added to the view:
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
I use progress as value between 0 and 1 where 0 is 0% progress and 1 is 100% progress.
Then to create a clipping path:
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
This is simply a path from center and creating an arc from zero angle to progress angle.
Now to apply this additional clipping:
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
We really just want to apply clipping when progress is not full. And we want to discard all drawing when progress is at zero since everything would be clipped.
You can see that the start angle of the shape is toward right instead of facing upward. Let's apply some transformation to fix that:
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
At this point the new view is capable of drawing and redrawing itself. You are free to use this in storyboard, you can add inspectables and make it designable if you will. As for the animation you are now only looking to animate a simple float value and assign it to progress. There are many ways to do that and I will do the laziest, which is using a timer:
#objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
This is pretty much it. A full code that I used to play around with this:
class ViewController: UIViewController {
private var progressView: GradientProgressView?
override func viewDidLoad() {
super.viewDidLoad()
let progressView = GradientProgressView(frame: .init(x: 30.0, y: 30.0, width: 280.0, height: 350.0))
progressView.backgroundColor = UIColor.lightGray // Just to debug
progressView.transform = CGAffineTransform(rotationAngle: -.pi*0.5)
progressView.points = {
let count = 200
let minimumRadius: CGFloat = 0.9
let maximumRadius: CGFloat = 1.1
return (0...count).map { index in
let progress: CGFloat = CGFloat(index) / CGFloat(count)
let angle = CGFloat.pi * 2.0 * progress
let radius = CGFloat.random(in: minimumRadius...maximumRadius)
return .init(x: cos(angle)*radius, y: sin(angle)*radius)
}
}()
view.addSubview(progressView)
self.progressView = progressView
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateProgress)))
}
#objc private func animateProgress() {
let duration: TimeInterval = 1.0
let startDate = Date()
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
let progress = Date().timeIntervalSince(startDate)/duration
if progress >= 1.0 {
timer.invalidate()
}
self.progressView?.progress = max(0.0, min(CGFloat(progress), 1.0))
}
}
}
private extension ViewController {
class GradientProgressView: UIView {
var points: [CGPoint]? { didSet { setNeedsDisplay() } }
var progress: CGFloat = 0.7 { didSet { setNeedsDisplay() } }
override func draw(_ rect: CGRect) {
super.draw(rect)
let actualProgress = max(0.0, min(progress, 1.0))
guard actualProgress > 0.0 else { return } // Nothing to draw
let willClipAsProgress = actualProgress < 1.0
guard let context = UIGraphicsGetCurrentContext() else { return }
let lineWidth: CGFloat = 5.0
guard let points = points else { return }
guard let path = generatePath(withPoints: points, inFrame: bounds.insetBy(dx: lineWidth, dy: lineWidth)) else { return }
if willClipAsProgress {
context.saveGState()
createProgressClippingPath().addClip()
}
drawGradient(path: path, context: context)
drawLine(path: path, lineWidth: lineWidth, context: context)
if willClipAsProgress {
context.restoreGState()
}
}
private func createProgressClippingPath() -> UIBezierPath {
let endAngle = CGFloat.pi*2.0*progress
let maxRadius: CGFloat = max(bounds.width, bounds.height) // we simply need one that is large enough.
let path = UIBezierPath()
let center: CGPoint = .init(x: bounds.midX, y: bounds.midY)
path.move(to: center)
path.addArc(withCenter: center, radius: maxRadius, startAngle: 0.0, endAngle: endAngle, clockwise: true)
return path
}
private func drawGradient(path: UIBezierPath, context: CGContext) {
context.saveGState()
path.addClip() // This will be discarded once restoreGState() is called
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: [UIColor.blue, UIColor.green].map { $0.cgColor } as CFArray, locations: [0.0, 1.0])!
context.drawRadialGradient(gradient, startCenter: CGPoint(x: bounds.midX, y: bounds.midY), startRadius: 0.0, endCenter: CGPoint(x: bounds.midX, y: bounds.midY), endRadius: min(bounds.width, bounds.height), options: [])
context.restoreGState()
}
private func drawLine(path: UIBezierPath, lineWidth: CGFloat, context: CGContext) {
UIColor.black.setStroke()
path.lineWidth = lineWidth
path.stroke()
}
private func generatePath(withPoints points: [CGPoint], inFrame frame: CGRect) -> UIBezierPath? {
guard points.count > 2 else { return nil } // At least 3 points
let pointsInPolarCoordinates: [(angle: CGFloat, radius: CGFloat)] = points.map { point in
let radius = (point.x*point.x + point.y*point.y).squareRoot()
let angle = atan2(point.y, point.x)
return (angle, radius)
}
let maximumPointRadius: CGFloat = pointsInPolarCoordinates.max(by: { $1.radius > $0.radius })!.radius
guard maximumPointRadius > 0.0 else { return nil } // Not all points may be centered
let maximumFrameRadius = min(frame.width, frame.height)*0.5
let radiusScale = maximumFrameRadius/maximumPointRadius
let normalizedPoints: [CGPoint] = pointsInPolarCoordinates.map { polarPoint in
.init(x: frame.midX + cos(polarPoint.angle)*polarPoint.radius*radiusScale,
y: frame.midY + sin(polarPoint.angle)*polarPoint.radius*radiusScale)
}
let path = UIBezierPath()
path.move(to: normalizedPoints[0])
normalizedPoints[1...].forEach { path.addLine(to: $0) }
path.close()
return path
}
}
}

Matic wrote a War and Peace length answer to your question using drawRect to create the animation you are after. While impressive, I don't recommend that approach. When you implement drawRect, you do all the drawing on a single core, using the main processor on the main thread. You don't take advantage of the hardware-accelerated rendering in iOS at all.
Instead, I would suggest using Core Animation and CALayers.
It looks to me like your animation is a classic "clock wipe" animation, where you animate your image into view as if you are painting it with the sweep second hand of a clock.
I created just such an animation quite a few years ago in Objective-C, and have since updated it to Swift. See this link for a description and a link to a Github project. The effect looks like this:
You'd then need to create the image you are after and install it as the contents of a view, and then use the clock wipe animation code to reveal it. I didn't look too closely at Matic's answer, but it appears to explain how to draw an image that looks like yours.

Related

How to make custom ripples like Square, Stare and other custom shapes in Swift 5?

I have facing issue to make ripples in Square and Stare figure like YRipple
Please help me and suggestion always welcome.
One easy way to achieve this is to use UIView animations. Each ripple is simply an instance of UIView. The shape can then be simply defined, drawn in one of many ways. I am using the override of draw rect method:
class RippleEffectView: UIView {
func addRipple(at location: CGPoint) {
let minRadius: CGFloat = 5.0
let maxRadius: CGFloat = 100.0
let startFrame = CGRect(x: location.x - minRadius, y: location.y - minRadius, width: minRadius*2.0, height: minRadius*2.0)
let endFrame = CGRect(x: location.x - maxRadius, y: location.y - maxRadius, width: maxRadius*2.0, height: maxRadius*2.0)
let view = ShapeView(frame: startFrame)
view.shape = .star(cornerCount: 5)
view.backgroundColor = .clear
view.contentMode = .redraw
view.strokeColor = .black
view.strokeWidth = 5.0
addSubview(view)
UIView.animate(withDuration: 1.0, delay: 0.0, options: [.allowUserInteraction]) {
view.frame = endFrame
view.alpha = 0.0
} completion: { _ in
view.removeFromSuperview()
}
}
}
private class ShapeView: UIView {
var fillColor: UIColor?
var strokeColor: UIColor?
var strokeWidth: CGFloat = 0.0
var shape: Shape = .rectangle
override func draw(_ rect: CGRect) {
super.draw(rect)
let path = generatePath()
path.lineWidth = strokeWidth
if let fillColor = fillColor {
fillColor.setFill()
path.fill()
}
if let strokeColor = strokeColor {
strokeColor.setStroke()
path.stroke()
}
}
private func generatePath() -> UIBezierPath {
switch shape {
case .rectangle: return UIBezierPath(rect: bounds.insetBy(dx: strokeWidth*0.5, dy: strokeWidth*0.5))
case .oval: return UIBezierPath(ovalIn: bounds.insetBy(dx: strokeWidth*0.5, dy: strokeWidth*0.5))
case .anglesOnCircle(let cornerCount):
guard cornerCount > 2 else { return .init() }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = min(bounds.width, bounds.height)*0.5 - strokeWidth*0.5
let path = UIBezierPath()
for index in 0..<cornerCount {
let angle = CGFloat(index)/CGFloat(cornerCount) * (.pi*2.0)
let point = CGPoint(x: center.x + cos(angle)*radius,
y: center.y + sin(angle)*radius)
if index == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
path.close()
return path
case .star(let cornerCount):
guard cornerCount > 2 else { return .init() }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let outerRadius = min(bounds.width, bounds.height)*0.5 - strokeWidth*0.5
let innerRadius = outerRadius*0.7
let path = UIBezierPath()
for index in 0..<cornerCount*2 {
let angle = CGFloat(index)/CGFloat(cornerCount) * .pi
let radius = index.isMultiple(of: 2) ? outerRadius : innerRadius
let point = CGPoint(x: center.x + cos(angle)*radius,
y: center.y + sin(angle)*radius)
if index == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
path.close()
return path
}
}
}
private extension ShapeView {
enum Shape {
case rectangle
case oval
case anglesOnCircle(cornerCount: Int)
case star(cornerCount: Int)
}
}
I used it in a view controller where I replaced main view with this ripple view in Storyboard.
class ViewController: UIViewController {
private var rippleView: RippleEffectView? { view as? RippleEffectView }
override func viewDidLoad() {
super.viewDidLoad()
rippleView?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap)))
}
#objc private func onTap(_ recognizer: UIGestureRecognizer) {
let location = recognizer.location(in: rippleView)
rippleView?.addRipple(at: location)
}
}
I hope the code speaks for itself. It should be no problem to change colors. You could apply some rotation by using transform on each ripple view...
You could even use images instead of shapes. If image is set to be as templates you could even change colors using tint property on image view... So limitless possibilities.

How to create a rotating rainbow color circle in iOS

From stackoverflow i got a code for drawing rainbow color circle.But as part of requirement ,I need that circle to be rotated continously ,like a rotating progress loader.Below is the code used for creating Rainbow color circle.
class RainbowCircle: UIView {
private var radius: CGFloat {
return frame.width>frame.height ? frame.height/2 : frame.width/2
}
private var stroke: CGFloat = 10
private var padding: CGFloat = 5
//MARK: - Drawing
override func draw(_ rect: CGRect) {
super.draw(rect)
drawRainbowCircle(outerRadius: radius - padding, innerRadius: radius - stroke - padding, resolution: 1)
}
init(frame: CGRect, lineHeight: CGFloat) {
super.init(frame: frame)
stroke = lineHeight
}
required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) }
/*
Resolution should be between 0.1 and 1
*/
private func drawRainbowCircle(outerRadius: CGFloat, innerRadius: CGFloat, resolution: Float) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.saveGState()
context.translateBy(x: self.bounds.midX, y: self.bounds.midY) //Move context to center
let subdivisions:CGFloat = CGFloat(resolution * 512) //Max subdivisions of 512
let innerHeight = (CGFloat.pi*innerRadius)/subdivisions //height of the inner wall for each segment
let outterHeight = (CGFloat.pi*outerRadius)/subdivisions
let segment = UIBezierPath()
segment.move(to: CGPoint(x: innerRadius, y: -innerHeight/2))
segment.addLine(to: CGPoint(x: innerRadius, y: innerHeight/2))
segment.addLine(to: CGPoint(x: outerRadius, y: outterHeight/2))
segment.addLine(to: CGPoint(x: outerRadius, y: -outterHeight/2))
segment.close()
//Draw each segment and rotate around the center
for i in 0 ..< Int(ceil(subdivisions)) {
UIColor(hue: CGFloat(i)/subdivisions, saturation: 1, brightness: 1, alpha: 1).set()
segment.fill()
//let lineTailSpace = CGFloat.pi*2*outerRadius/subdivisions //The amount of space between the tails of each segment
let lineTailSpace = CGFloat.pi*2*outerRadius/subdivisions
segment.lineWidth = lineTailSpace //allows for seemless scaling
segment.stroke()
// //Rotate to correct location
let rotate = CGAffineTransform(rotationAngle: -(CGFloat.pi*2/subdivisions)) //rotates each segment
segment.apply(rotate)
}
Please anyone help me in rotating this circle.
Please find below the circle generated with above code:
What you got looks completely overcomplicated in the first place. Take a look at the following example:
class ViewController: UIViewController {
class RainbowView: UIView {
var segmentCount: Int = 10 {
didSet {
refresh()
}
}
var lineWidth: CGFloat = 10 {
didSet {
refresh()
}
}
override var frame: CGRect {
didSet {
refresh()
}
}
override func layoutSubviews() {
super.layoutSubviews()
refresh()
}
private var currentGradientLayer: CAGradientLayer?
private func refresh() {
currentGradientLayer?.removeFromSuperlayer()
guard segmentCount > 0 else { return }
currentGradientLayer = {
let gradientLayer = CAGradientLayer()
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.type = .conic
let colors: [UIColor] = {
var colors: [UIColor] = [UIColor]()
for i in 0..<segmentCount {
colors.append(UIColor(hue: CGFloat(i)/CGFloat(segmentCount), saturation: 1, brightness: 1, alpha: 1))
}
colors.append(UIColor(hue: 0.0, saturation: 1, brightness: 1, alpha: 1)) // Append start color at the end as well to complete the circle
return colors;
}()
gradientLayer.colors = colors.map { $0.cgColor }
gradientLayer.frame = bounds
layer.addSublayer(gradientLayer)
gradientLayer.mask = {
let shapeLayer = CAShapeLayer()
shapeLayer.frame = bounds
shapeLayer.lineWidth = lineWidth
shapeLayer.strokeColor = UIColor.white.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.path = UIBezierPath(ovalIn: bounds.inset(by: UIEdgeInsets(top: lineWidth*0.5, left: lineWidth*0.5, bottom: lineWidth*0.5, right: lineWidth*0.5))).cgPath
return shapeLayer
}()
return gradientLayer
}()
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview({
let view = RainbowView(frame: CGRect(x: 50.0, y: 100.0, width: 100.0, height: 100.0))
var angle: CGFloat = 0.0
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true, block: { _ in
angle += 0.01
view.transform = CGAffineTransform(rotationAngle: angle)
})
return view
}())
}
}
So a view is generated that uses a conical gradient with mask to draw the circle you are describing. Then a transform is applied to the view to rotate it. And a Timer is scheduled to rotate the circle.
Note that this code will leak because timer is nowhere invalidated. It needs to be removed when view disappears or similar.
The easiest way would be to attach an animation that repeats forever:
let animation = CABasicAnimation(keyPath: "transform.rotation") // Create rotation animation
animation.repeatCount = .greatestFiniteMagnitude // Repeat animation for as long as we can
animation.fromValue = 0 // Rotate from 0
animation.toValue = 2 * Float.pi // to 360 deg
animation.duration = 1 // During 1 second
self.layer.add(animation, forKey: "animation") // Adding the animation to the view
self - is RainbowCircle, assuming that you add this code to one of the methods inside it.
For this we can have Image something like this
syncImage.image = UIImage(named:"spinning")
Create a below extension to Start/Stop Rotating
extension UIView {
// To animate
func startRotating(duration: Double = 1) {
let kAnimationKey = "rotation"
if self.layer.animationForKey(kAnimationKey) == nil {
let animate = CABasicAnimation(keyPath: "transform.rotation")
animate.duration = duration
animate.repeatCount = Float.infinity
animate.fromValue = 0.0
animate.toValue = Float(M_PI * 2.0)
self.layer.addAnimation(animate, forKey: kAnimationKey)
}
}
func stopRotating() {
let kAnimationKey = "rotation"
if self.layer.animationForKey(kAnimationKey) != nil {
self.layer.removeAnimationForKey(kAnimationKey)
}
}
}
Usage
func startSpinning() {
syncImage.startRotating()
}
func stopSpinning() {
syncImage.stopRotating()
}
func handleSyncTap(sender: UITapGestureRecognizer? = nil) {
startSpinning()
let dispatchTime: dispatch_time_t = dispatch_time(DISPATCH_TIME_NOW, Int64(3 * Double(NSEC_PER_SEC)))
dispatch_after(dispatchTime, dispatch_get_main_queue(), {
self.stopSpinning()
})
}

Animate UISlider's buffer track change?

Following an online article and using a github project I was able to create a UISlider with a second track (bufferTrack).
The only problem I am facing is with updating the burrerEndValue / value. It is not animated. How could I achieve a smooth animation on the UIBezierPath?
open class BufferSlider: UISlider {
open var bufferStartValue:Double = 0 {
didSet{
if bufferStartValue < 0.0 {
bufferStartValue = 0
}
if bufferStartValue > bufferEndValue {
bufferStartValue = bufferEndValue
}
self.setNeedsDisplay()
}
}
open var bufferEndValue:Double = 0 {
didSet{
if bufferEndValue > 1.0 {
bufferEndValue = 1
}
if bufferEndValue < bufferStartValue{
bufferEndValue = bufferStartValue
}
self.setNeedsDisplay()
}
}
open var baseColor:UIColor = UIColor.white
open var progressColor:UIColor?
open var bufferColor:UIColor?
open var customBorderWidth: Double = 0.1{
didSet{
if customBorderWidth < 0.1 {
customBorderWidth = 0.1
}
self.setNeedsDisplay()
}
}
open var sliderHeight: Double = 6 {
didSet{
if sliderHeight < 1 {
sliderHeight = 1
}
}
}
override open func setValue(_ value: Float, animated: Bool) {
super.setValue(value, animated: animated)
self.setNeedsDisplay()
}
override init(frame: CGRect) {
super.init(frame: frame)
updateView()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
updateView()
}
func updateView() {
baseColor = UIColor.white
progressColor = appColors.red
bufferColor = appColors.fadedRed
}
open override func trackRect(forBounds bounds: CGRect) -> CGRect {
var result = super.trackRect(forBounds: bounds)
result.size.height = 0.01
return result
}
open override func draw(_ rect: CGRect) {
baseColor.set()
let rect = self.bounds.insetBy(dx: CGFloat(customBorderWidth), dy: CGFloat(customBorderWidth))
let height = sliderHeight.CGFloatValue
let radius = height/2
let sliderRect = CGRect(x: rect.origin.x, y: rect.origin.y + (rect.height/2-radius), width: rect.width, height: rect.width) //default center
let path = UIBezierPath()
path.addArc(withCenter: CGPoint(x: sliderRect.minX + radius, y: sliderRect.minY+radius), radius: radius, startAngle: CGFloat(Double.pi)/2, endAngle: -CGFloat(Double.pi)/2, clockwise: true)
path.addLine(to: CGPoint(x: sliderRect.maxX-radius, y: sliderRect.minY))
path.addArc(withCenter: CGPoint(x: sliderRect.maxX-radius, y: sliderRect.minY+radius), radius: radius, startAngle: -CGFloat(Double.pi)/2, endAngle: CGFloat(Double.pi)/2, clockwise: true)
path.addLine(to: CGPoint(x: sliderRect.minX + radius, y: sliderRect.minY+height))
baseColor.setStroke()
path.lineWidth = customBorderWidth.CGFloatValue
path.stroke()
path.fill()
path.addClip()
var fillHeight = sliderRect.size.height-customBorderWidth.CGFloatValue
if fillHeight < 0 {
fillHeight = 0
}
let fillRect = CGRect(
x: sliderRect.origin.x + sliderRect.size.width*CGFloat(bufferStartValue),
y: sliderRect.origin.y + customBorderWidth.CGFloatValue/2,
width: sliderRect.size.width*CGFloat(bufferEndValue-bufferStartValue),
height: fillHeight)
if let color = bufferColor { color.setFill() }
else if let color = self.superview?.tintColor{ color.setFill()}
else{ UIColor(red: 0.0, green: 122.0/255.0, blue: 1.0, alpha: 1.0).setFill() }
UIBezierPath(rect: fillRect).fill()
if let color = progressColor{
color.setFill()
let fillRect = CGRect(
x: sliderRect.origin.x,
y: sliderRect.origin.y + customBorderWidth.CGFloatValue/2,
width: sliderRect.size.width*CGFloat((value-minimumValue)/(maximumValue-minimumValue)),
height: fillHeight)
UIBezierPath(rect: fillRect).fill()
}
}
}
extension Double{
var CGFloatValue: CGFloat {
return CGFloat(self)
}
}
You can't animate the UIView instance when you implemented custom drawing for it in draw(rect:) function, because during animation self.layer.presentationLayer has to be drawn with interpolated values between oldValue and newValue, but your overridden logic of drawing draws always with the newly set value newValue.
You can do custom drawing with animations that you want only in CALayer instance.
Consider implementing of your drawing logic in BufferSliderLayer: CALayer.
For animations on the layer, you'd need to interpolate values that you want to animate, e.g. bufferEndValue and value.
In order to do that, you can refer to this article.
Then, just add BufferSliderLayer onto your BufferSlider view's layer in slider's init(frame:) initialiser and properly size your layer in layoutSubviews.

Apple Workout Rings SpriteKit Animation

I am trying to re-create the Apple Workout Rings in my WatchOS App. I am making use of SpriteKit and GameScene for the animation. However, I am not able to understand how to implement the overlapping rings and include a gradient.
Workout Rings
I tried using SKShader in order to incorporate the gradient effect. However, SKShapeNode ignores the line cap when SKShader is present so I'm not able to get the rounded edges.
I have also looked at other approaches like : Circle Progress View like activity app
However, I don't know how to use this approach for the watchOS as SpriteKit works on the concept of nodes and this approach deals with CGContext.
class GameScene: SKScene {
func circle(radius:CGFloat, percent:CGFloat) -> CGPath {
let start:CGFloat = 0
let end = ((CGFloat.pi * 2)) * percent
let center = CGPoint.zero
let corePath = CGMutablePath()
corePath.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: true)
return corePath
}
// Animated Timer for Progress Circle
func countdownCircle(circle:SKShapeNode, steps:Int, duration:TimeInterval, completion:#escaping ()->Void) {
guard let path = circle.path else {
return
}
let radius = path.boundingBox.width/2
let timeInterval = duration/TimeInterval(steps)
let increment = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.run {
percent -= increment
circle.path = self.circle(radius: radius, percent:percent)
}
let wait = SKAction.wait(forDuration:timeInterval)
let action = SKAction.sequence([wait, animate])
run(SKAction.repeatForever(action)) {
self.run(SKAction.wait(forDuration:timeInterval/2)) {
circle.path = nil
completion()
}
//(action,count:steps-1)
}
}
// Animated Timer for Shadow Circle
func countdownShadow(circle:SKShapeNode, steps:Int, duration:TimeInterval, completion:#escaping ()->Void) {
guard let path = circle.path else {
return
}
let radius = path.boundingBox.width/2
let timeInterval = duration/TimeInterval(steps)
let increment = 1 / CGFloat(steps)
var percent = CGFloat(1.0)
let animate = SKAction.run {
percent -= increment
circle.path = self.circle(radius: radius, percent:percent)
}
let wait = SKAction.wait(forDuration:timeInterval)
let action = SKAction.sequence([wait, animate])
run(SKAction.repeatForever(action)) {
self.run(SKAction.wait(forDuration:timeInterval)) {
circle.path = nil
completion()
}
}
}
//(action,count:steps-1)
override func sceneDidLoad() {
let pathForCircle = CGMutablePath()
pathForCircle.addArc(center: CGPoint.zero, radius: 100, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true)
// This is the circle that indicates the progress.
let progressCircle = SKShapeNode()
progressCircle.lineCap = .round
progressCircle.position = CGPoint(x: 0, y: 15)
progressCircle.strokeColor = SKColor.green
progressCircle.lineWidth = 20
progressCircle.path = pathForCircle
progressCircle.zPosition = 4
self.addChild(progressCircle)
countdownCircle(circle: progressCircle, steps: 400, duration: 5){
print("Done")
}
// This is the circle that gives the ring the shadow effect.
let shadowCircle = SKShapeNode()
shadowCircle.lineCap = .round
shadowCircle.position = CGPoint(x: 0, y: 15)
shadowCircle.strokeColor = SKColor.black
shadowCircle.glowWidth = 30
shadowCircle.zPosition = 3
shadowCircle.path = pathForCircle
self.addChild(shadowCircle)
countdownShadow(circle: shadowCircle, steps: 400, duration: 5){
print("Done")
}
// This is the bottommost circle.
let bottommostCircle = SKShapeNode()
bottommostCircle.position = CGPoint(x: 0, y: 15)
bottommostCircle.lineCap = .butt
bottommostCircle.strokeColor = SKColor.green
bottommostCircle.alpha = 0.2
bottommostCircle.lineWidth = 20
bottommostCircle.path = pathForCircle
bottommostCircle.zPosition = 0
self.addChild(bottommostCircle)
}
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
}
}
there are plenty of Spritekit implementations of this exact sort on GitHub
Here is one that looks particularly good.
https://github.com/HarshilShah/ActivityRings

Create a Diagonal Custom UIView in Swift

I am working on designing custom UIimageview in swift. I want to create a UIimageview using beizerpath similar to this
The coding should be in swift.
Any help is appreciated. Thanks
Create a CAShapeLayer and supply it a path and a fillColor:
#IBDesignable
public class AngleView: UIView {
#IBInspectable public var fillColor: UIColor = .blue { didSet { setNeedsLayout() } }
var points: [CGPoint] = [
.zero,
CGPoint(x: 1, y: 0),
CGPoint(x: 1, y: 1),
CGPoint(x: 0, y: 0.5)
] { didSet { setNeedsLayout() } }
private lazy var shapeLayer: CAShapeLayer = {
let _shapeLayer = CAShapeLayer()
self.layer.insertSublayer(_shapeLayer, at: 0)
return _shapeLayer
}()
override public func layoutSubviews() {
shapeLayer.fillColor = fillColor.cgColor
guard points.count > 2 else {
shapeLayer.path = nil
return
}
let path = UIBezierPath()
path.move(to: convert(relativePoint: points[0]))
for point in points.dropFirst() {
path.addLine(to: convert(relativePoint: point))
}
path.close()
shapeLayer.path = path.cgPath
}
private func convert(relativePoint point: CGPoint) -> CGPoint {
return CGPoint(x: point.x * bounds.width + bounds.origin.x, y: point.y * bounds.height + bounds.origin.y)
}
}
Now, I made this designable (so if you put it in a separate framework target, you can add this view right in your storyboard and see it rendered there). It still works if you're not using storyboards. It just can be convenient to do so:
I also used relative coordinates (with values ranging from zero to one) and have a method to convert those to actual coordinates, but you can hard code your coordinates if you want. But using this as values from zero to one, you have an angular view that can participate in auto-layout without needing to worry about changing specific coordinate values.
Finally, minor things that might seem trivial, but I construct the path in layoutSubviews: That way, as the view changes size (whether via auto-layout or programmatic changes), the view will be correctly re-rendered. Likewise, by using didSet for fillColor and points, if you change either of those, the view will be re-rendered for you.
Feel free to change this as you see fit, but hopefully this illustrates the basic idea of just having a CAShapeLayer with a custom path.
If you use insertSublayer, you can then combine this with other subviews of the AngleView, e.g.:
I'm using something like this and it worked fine, you can add any thing you want to the view
import UIKit
class CustomView: UIView {
// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
// Drawing code
// Get Height and Width
let layerHeight = layer.frame.height
let layerWidth = layer.frame.width
// Create Path
let bezierPath = UIBezierPath()
// Points
let pointA = CGPoint(x: 0, y: 0)
let pointB = CGPoint(x: layerWidth, y: 0)
let pointC = CGPoint(x: layerWidth, y: layerHeight)
let pointD = CGPoint(x: 0, y: layerHeight*2/3)
// Draw the path
bezierPath.move(to: pointA)
bezierPath.addLine(to: pointB)
bezierPath.addLine(to: pointC)
bezierPath.addLine(to: pointD)
bezierPath.close()
// Mask to Path
let shapeLayer = CAShapeLayer()
shapeLayer.path = bezierPath.cgPath
layer.mask = shapeLayer
}
}

Resources