Show IOS CABasicAnimation progress in label [duplicate] - ios

I am trying to add a countdown to my app and I'm having problems animating this. The look I'm going for is something similar to the iPad countdown shown below, with the red bar increasing as the clock counts down.
Initially I created an image atlas with an individual image for each half second of the countdown but this seems to take a lot of memory to run and therefore crashes the app so I now have to find an alternative.
I've been looking at colorizing here https://developer.apple.com/library/prerelease/ios/documentation/GraphicsAnimation/Conceptual/SpriteKit_PG/Sprites/Sprites.html#//apple_ref/doc/uid/TP40013043-CH9 but can't see a way of colorizing a section of the sprite, it seems that the whole sprite would be changed.
Does anyone know if colorizing would be an option here, or is there a way of reduce the memory used by an image atlas?
Thanks

You can do it using CAShapeLayer and animating the stroke end as follow:
update Xcode 9 • Swift 4
define the time left
let timeLeftShapeLayer = CAShapeLayer()
let bgShapeLayer = CAShapeLayer()
var timeLeft: TimeInterval = 60
var endTime: Date?
var timeLabel = UILabel()
var timer = Timer()
// here you create your basic animation object to animate the strokeEnd
let strokeIt = CABasicAnimation(keyPath: "strokeEnd")
define a method to create de UIBezierPath
startAngle at -90˚ and endAngle 270
func drawBgShape() {
bgShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
bgShapeLayer.strokeColor = UIColor.white.cgColor
bgShapeLayer.fillColor = UIColor.clear.cgColor
bgShapeLayer.lineWidth = 15
view.layer.addSublayer(bgShapeLayer)
}
func drawTimeLeftShape() {
timeLeftShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
timeLeftShapeLayer.strokeColor = UIColor.red.cgColor
timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
timeLeftShapeLayer.lineWidth = 15
view.layer.addSublayer(timeLeftShapeLayer)
}
add your Label
func addTimeLabel() {
timeLabel = UILabel(frame: CGRect(x: view.frame.midX-50 ,y: view.frame.midY-25, width: 100, height: 50))
timeLabel.textAlignment = .center
timeLabel.text = timeLeft.time
view.addSubview(timeLabel)
}
at viewDidload set the endTime and add your CAShapeLayer to your view:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.94, alpha: 1.0)
drawBgShape()
drawTimeLeftShape()
addTimeLabel()
// here you define the fromValue, toValue and duration of your animation
strokeIt.fromValue = 0
strokeIt.toValue = 1
strokeIt.duration = timeLeft
// add the animation to your timeLeftShapeLayer
timeLeftShapeLayer.add(strokeIt, forKey: nil)
// define the future end time by adding the timeLeft to now Date()
endTime = Date().addingTimeInterval(timeLeft)
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)
}
when updating the time
#objc func updateTime() {
if timeLeft > 0 {
timeLeft = endTime?.timeIntervalSinceNow ?? 0
timeLabel.text = timeLeft.time
} else {
timeLabel.text = "00:00"
timer.invalidate()
}
}
you can use this extension to convert the degrees to radians and display time
extension TimeInterval {
var time: String {
return String(format:"%02d:%02d", Int(self/60), Int(ceil(truncatingRemainder(dividingBy: 60))) )
}
}
extension Int {
var degreesToRadians : CGFloat {
return CGFloat(self) * .pi / 180
}
}
Sample project

Great piece of code and answer! Just thought I'd suggest the following. I was getting the time showing as 1:00 and then 0:60 which I think might be due to the "ceil" part of the code.
I changed it to the following (also wanting times less than 60 to show differently) and it seems to have removed the overlap of times.
For what it's worth ...
extension NSTimeInterval {
var time:String {
if self < 60 {
return String(format: "%02d", Int(floor(self%60)))
} else
{
return String(format: "%01d:%02d", Int(self/60), Int(floor(self%60)))
}
}
}

Thanks for the great answer from #Leo Dabus, and I see someone asking why the code didn't work / not able to implement on their own project. Here are some tips to keep in mind when using CABasicAnimation:
We should add the animation to the layer that ALREADY exists in the view, i.e. ensure calling layer.add after view.layer.addSublayer.
Try not to do animation in viewDidLoad, as we can't tell if the layer already exists.
So we can try something like:
override func viewDidLoad() {
super.viewDidLoad()
drawBgShape()
drawTimeLeftShape()
...
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// move the setup of animation here
strokeIt.fromValue = 0
strokeIt.toValue = 1
strokeIt.duration = timeLeft
timeLeftShapeLayer.add(strokeIt, forKey: nil)
}
Attached some great reference where help me to make the idea clear. Hope this can help and have a nice day ;)
https://stackoverflow.com/a/13066094/11207700
https://stackoverflow.com/a/43006847/11207700

Related

Core Animation - modify animation property

I have animation
func startRotate360() {
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 1
rotation.isCumulative = true
rotation.repeatCount = Float.greatestFiniteMagnitude
self.layer.add(rotation, forKey: "rotationAnimation")
}
What I want is ability to stop animation by setting its repeat count to 1, so it completes current rotation (simply remove animation is not ok because it looks not good)
I try following
func stopRotate360() {
self.layer.animation(forKey: "rotationAnimation")?.repeatCount = 1
}
But I get crash and in console
attempting to modify read-only animation
How to access writable properties ?
Give this a go. You can in fact change CAAnimations that are in progress. There are so many ways. This is the fastest/simplest. You could even stop the animation completely and resume it without the user even noticing.
You can see the start animation function along with the stop. The start animation looks similar to yours while the stop grabs the current rotation from the presentation layer and creates an animation to rotate until complete. I also smoothed out the duration to be a percentage of the time needed to complete based on current rotation z to full rotation based on the running animation. Then I remove the animation with the repeat count and add the new animation. You can see the view rotate smoothly to the final position and stop. You will get the idea. Drop it in and run it and see what you think. Hit the button to start and hit it again to see it finish rotation and stop.
import UIKit
class ViewController: UIViewController {
var animationView = UIView()
var button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
animationView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
animationView.backgroundColor = .green
animationView.center = view.center
self.view.addSubview(animationView)
let label = UILabel(frame: animationView.bounds)
label.text = "I Spin"
animationView.addSubview(label)
button = UIButton(frame: CGRect(x: 20, y: animationView.frame.maxY + 60, width: view.bounds.width - 40, height: 40))
button.setTitle("Animate", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.addTarget(self, action: #selector(ViewController.pressed), for: .touchUpInside)
self.view.addSubview(button)
}
func pressed(){
if let title = button.titleLabel?.text{
let trans = CATransition()
trans.type = "rippleEffect"
trans.duration = 0.6
button.layer.add(trans, forKey: nil)
switch title {
case "Animate":
//perform animation
button.setTitle("Stop And Finish", for: .normal)
rotateAnimationRepeat()
break
default:
//stop and finish
button.setTitle("Animate", for: .normal)
stopAnimationAndFinish()
break
}
}
}
func rotateAnimationRepeat(){
//just to be sure because of how i did the project
animationView.layer.removeAllAnimations()
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 0.5
rotation.repeatCount = Float.greatestFiniteMagnitude
//not doing cumlative
animationView.layer.add(rotation, forKey: "rotationAnimation")
}
func stopAnimationAndFinish(){
if let presentation = animationView.layer.presentation(){
if let currentRotation = presentation.value(forKeyPath: "transform.rotation.z") as? CGFloat{
var duration = 0.5
//smooth out duration for change
duration = Double((CGFloat(Double.pi * 2) - currentRotation))/(Double.pi * 2)
animationView.layer.removeAllAnimations()
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = currentRotation
rotation.toValue = Double.pi * 2
rotation.duration = duration * 0.5
animationView.layer.add(rotation, forKey: "rotationAnimation")
}
}
}
}
Result:
2019 typical modern syntax
Setup the arc and the layer like this:
import Foundation
import UIKit
class RoundChaser: UIView {
private let lineThick: CGFloat = 10.0
private let beginFraction: CGFloat = 0.15
// where does the arc drawing begin?
// 0==top, .25==right, .5==bottom, .75==left
private lazy var arcPath: CGPath = {
let b = beginFraction * .pi * 2.0
return UIBezierPath(
arcCenter: bounds.centerOfCGRect(),
radius: bounds.width / 2.0 - lineThick / 2.0,
startAngle: .pi * -0.5 + b,
// recall that .pi * -0.5 is the "top"
endAngle: .pi * 1.5 + b,
clockwise: true
).cgPath
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
l.path = arcPath
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.purple.cgColor
l.lineWidth = lineThick
l.lineCap = CAShapeLayerLineCap.round
l.strokeStart = 0
l.strokeEnd = 0
// if both are same, it is hidden. initially hidden
layer.addSublayer(l)
return l
}()
then initialization is this easy
open override func layoutSubviews() {
super.layoutSubviews()
arcLayer.frame = bounds
}
finally animation is easy
public func begin() {
CATransaction.begin()
let e : CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
e.duration = 2.0
e.fromValue = 0
e.toValue = 1.0
// recall 0 will be our beginFraction, see above
e.repeatCount = .greatestFiniteMagnitude
self.arcLayer.add(e, forKey: nil)
CATransaction.commit()
}
}
Maybe this is not the best solution but it works, as you say you can not modify properties of the CABasicAnimation once is created, also we need to remove the rotation.repeatCount = Float.greatestFiniteMagnitude, if notCAAnimationDelegatemethodanimationDidStop` is never called, with this approach the animation can be stoped without any problems as you need
step 1: first declare a variable flag to mark as you need stop animation in your custom class
var needStop : Bool = false
step 2: add a method to stopAnimation after ends
func stopAnimation()
{
self.needStop = true
}
step 3: add a method to get your custom animation
func getRotate360Animation() ->CAAnimation{
let rotation : CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = Double.pi * 2
rotation.duration = 1
rotation.isCumulative = true
rotation.isRemovedOnCompletion = false
return rotation
}
step 4: Modify your startRotate360 func to use your getRotate360Animation() method
func startRotate360() {
let rotationAnimation = self.getRotate360Animation()
rotationAnimation.delegate = self
self.layer.add(rotationAnimation, forKey: "rotationAnimation")
}
step 5: Implement CAAnimationDelegate in your class
extension YOURCLASS : CAAnimationDelegate
{
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if(anim == self.layer?.animation(forKey: "rotationAnimation"))
{
self.layer?.removeAnimation(forKey: "rotationAnimation")
if(!self.needStop){
let animation = self.getRotate360Animation()
animation.delegate = self
self.layer?.add(animation, forKey: "rotationAnimation")
}
}
}
}
This works and was tested
Hope this helps you

Can't get good performance with Draw(_ rect: CGRect)

I have a Timer() that asks the view to refresh every x seconds:
func updateTimer(_ stopwatch: Stopwatch) {
stopwatch.counter = stopwatch.counter + 0.035
Stopwatch.circlePercentage += 0.035
stopwatchViewOutlet.setNeedsDisplay()
}
--
class StopwatchView: UIView, UIGestureRecognizerDelegate {
let buttonClick = UITapGestureRecognizer()
override func draw(_ rect: CGRect) {
Stopwatch.drawStopwatchFor(view: self, gestureRecognizers: buttonClick)
}
}
--
extension Stopwatch {
static func drawStopwatchFor(view: UIView, gestureRecognizers: UITapGestureRecognizer) {
let scale: CGFloat = 0.8
let joltButtonView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 75, height: 75))
let imageView: UIImageView!
// -- Need to hook this to the actual timer --
let temporaryVariableForTimeIncrement = CGFloat(circlePercentage)
// Exterior of sphere:
let timerRadius = min(view.bounds.size.width, view.bounds.size.height) / 2 * scale
let timerCenter = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
let path = UIBezierPath(arcCenter: timerCenter, radius: timerRadius, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)
path.lineWidth = 2.0
UIColor.blue.setStroke()
path.stroke()
// Interior of sphere
let startAngle = -CGFloat.pi / 2
let arc = CGFloat.pi * 2 * temporaryVariableForTimeIncrement / 100
let cPath = UIBezierPath()
cPath.move(to: timerCenter)
cPath.addLine(to: CGPoint(x: timerCenter.x + timerRadius * cos(startAngle), y: timerCenter.y))
cPath.addArc(withCenter: timerCenter, radius: timerRadius * CGFloat(0.99), startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
cPath.addLine(to: CGPoint(x: timerCenter.x, y: timerCenter.y))
let circleShape = CAShapeLayer()
circleShape.path = cPath.cgPath
circleShape.fillColor = UIColor.cyan.cgColor
view.layer.addSublayer(circleShape)
// Jolt button
joltButtonView.center = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
joltButtonView.layer.cornerRadius = 38
joltButtonView.backgroundColor = UIColor.white
imageView = UIImageView(frame: joltButtonView.frame)
imageView.contentMode = .scaleAspectFit
imageView.image = image
joltButtonView.addGestureRecognizer(gestureRecognizers)
view.addSubview(joltButtonView)
view.addSubview(imageView)
}
}
My first problem is that the subviews are getting redrawn each time. The second one is that after a couple seconds, the performance starts to deteriorate really fast.
Too many instances of the subview
Trying to drive the blue graphic with a Timer()
Should I try to animate the circle percentage graphic instead of redrawing it each time the timer function is called?
The major observation is that you should not be adding subviews in draw. That is intended for rendering a single frame and you shouldn't be adding/removing things from the view hierarchy inside draw. Because you're adding subviews roughly ever 0.035 seconds, your view hierarchy is going to explode, with adverse memory and performance impact.
You should either have a draw method that merely calls stroke on your updated UIBezierPath for the seconds hand. Or, alternatively, if using CAShapeLayer, just update its path (and no draw method is needed at all).
So, a couple of peripheral observations:
You are incrementing your "percentage" for every tick of your timer. But you are not guaranteed the frequency with which your timer will be called. So, instead of incrementing some "percentage", you should save the start time when you start the timer, and then upon every tick of the timer, you should recalculate the amount of time elapsed between then and now when figuring out how much time has elapsed.
You are using a Timer which is good for most purposes, but for the sake of optimal drawing, you really should use a CADisplayLink, which is optimally timed to allow you to do whatever you need before the next refresh of the screen.
So, here is an example of a simple clock face with a sweeping second hand:
open class StopWatchView: UIView {
let faceLineWidth: CGFloat = 5 // LineWidth of the face
private var startTime: Date? { didSet { self.updateHand() } } // When was the stopwatch resumed
private var oldElapsed: TimeInterval? { didSet { self.updateHand() } } // How much time when stopwatch paused
public var elapsed: TimeInterval { // How much total time on stopwatch
guard let startTime = startTime else { return oldElapsed ?? 0 }
return Date().timeIntervalSince(startTime) + (oldElapsed ?? 0)
}
private weak var displayLink: CADisplayLink? // Display link animating second hand
public var isRunning: Bool { return displayLink != nil } // Is the timer running?
private var clockCenter: CGPoint { // Center of the clock face
return CGPoint(x: bounds.midX, y: bounds.midY)
}
private var radius: CGFloat { // Radius of the clock face
return (min(bounds.width, bounds.height) - faceLineWidth) / 2
}
private lazy var face: CAShapeLayer = { // Shape layer for clock face
let shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = self.faceLineWidth
shapeLayer.strokeColor = #colorLiteral(red: 0.1215686277, green: 0.01176470611, blue: 0.4235294163, alpha: 1).cgColor
shapeLayer.fillColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0).cgColor
return shapeLayer
}()
private lazy var hand: CAShapeLayer = { // Shape layer for second hand
let shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = 3
shapeLayer.strokeColor = #colorLiteral(red: 0.2196078449, green: 0.007843137719, blue: 0.8549019694, alpha: 1).cgColor
shapeLayer.fillColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0).cgColor
return shapeLayer
}()
override public init(frame: CGRect = .zero) {
super.init(frame: frame)
configure()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
// add necessary shape layers to layer hierarchy
private func configure() {
layer.addSublayer(face)
layer.addSublayer(hand)
}
// if laying out subviews, make sure to resize face and update hand
override open func layoutSubviews() {
super.layoutSubviews()
updateFace()
updateHand()
}
// stop display link when view disappears
//
// this prevents display link from keeping strong reference to view after view is removed
override open func willMove(toSuperview newSuperview: UIView?) {
if newSuperview == nil {
pause()
}
}
// MARK: - DisplayLink routines
/// Start display link
open func resume() {
// cancel any existing display link, if any
pause()
// start new display link
startTime = Date()
let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
displayLink.add(to: .main, forMode: .commonModes)
// save reference to it
self.displayLink = displayLink
}
/// Stop display link
open func pause() {
displayLink?.invalidate()
// calculate floating point number of seconds
oldElapsed = elapsed
startTime = nil
}
open func reset() {
pause()
oldElapsed = nil
}
/// Display link handler
///
/// Will update path of second hand.
#objc func handleDisplayLink(_ displayLink: CADisplayLink) {
updateHand()
}
/// Update path of clock face
private func updateFace() {
face.path = UIBezierPath(arcCenter: clockCenter, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true).cgPath
}
/// Update path of second hand
private func updateHand() {
// convert seconds to an angle (in radians) and figure out end point of seconds hand on the basis of that
let angle = CGFloat(elapsed / 60 * 2 * .pi - .pi / 2)
let endPoint = CGPoint(x: clockCenter.x + cos(angle) * radius * 0.9, y: clockCenter.y + sin(angle) * radius * 0.9)
// update path of hand
let path = UIBezierPath()
path.move(to: clockCenter)
path.addLine(to: endPoint)
hand.path = path.cgPath
}
}
And
class ViewController: UIViewController {
#IBOutlet weak var stopWatchView: StopWatchView!
#IBAction func didTapStartStopButton () {
if stopWatchView.isRunning {
stopWatchView.pause()
} else {
stopWatchView.resume()
}
}
#IBAction func didTapResetButton () {
stopWatchView.reset()
}
}
Now, in the above example, I'm just updating the CAShapeLayer of the seconds hand in my CADisplayLink handler. Like I said at the start, you can alternatively have a draw method that simply strokes the paths you need for single frame of animation and then call setNeedsDisplay in your display link handler. But if you do that, don't change the view hierarchy within draw, but rather do any configuration you need in init and draw should just stroke whatever the path should be at that moment.

iOS SpriteKit collision with screen edges not working

I am trying to create a collision between an SKShapeNode instance and the edges of the screen but for some reason the ShapeNode still goes beyond the limits of the screen. I have implemented code that I believe should take care of the collision but I am new to SpriteKit so I am not entirely sure. Here's a snippet of the code:
//
// GameScene.swift
// SpriteKitTest
//
// Created by 580380 on 3/10/16.
// Copyright (c) 2016 580380. All rights reserved.
//
import SpriteKit
import UIKit
import CoreMotion
class GameScene: SKScene {
//MARK: - Global variables
let motionManager = CMMotionManager()
var circleNode = SKShapeNode()
var destX : CGFloat = 0.0
var destY : CGFloat = 0.0
enum Collision :UInt32 {
case ball = 1
case wall = 2
}
//MARK: - Sprite kit functionality
override func didMoveToView(view: SKView) {
backgroundColor = UIColor.whiteColor()
createCircleNode()
moveCircleNode()
self.addChild(circleNode)
}
//Setup and configuring the SKShapeNode object
private func createCircleNode() {
circleNode.path = UIBezierPath(arcCenter: CGPoint(x: CGRectGetMidX(self.frame), y: CGRectGetMidY(self.frame)), radius: 20, startAngle: 0, endAngle: CGFloat(2*M_PI), clockwise: true).CGPath
circleNode.fillColor = UIColor.redColor()
circleNode.strokeColor = UIColor.blueColor()
circleNode.lineWidth = 1
}
private func moveCircleNode() {
circleNode.physicsBody = SKPhysicsBody()
circleNode.physicsBody?.dynamic = true
circleNode.physicsBody?.affectedByGravity = false
if motionManager.accelerometerAvailable {
motionManager.accelerometerUpdateInterval = 0.1
motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.mainQueue(), withHandler: { (data, error) -> Void in
self.destX = self.circleNode.position.x + CGFloat(data!.acceleration.x*100)
self.destY = self.circleNode.position.y + CGFloat(data!.acceleration.y*200)
})
}
self.physicsBody = SKPhysicsBody(edgeLoopFromRect: self.frame)
self.physicsBody!.dynamic = true
self.physicsBody!.affectedByGravity = false
self.physicsBody!.categoryBitMask = Collision.wall.rawValue
self.physicsBody!.collisionBitMask = Collision.ball.rawValue
}
override func update(currentTime: NSTimeInterval) {
let destXAction = SKAction.moveToX(destX, duration: 0.1)
let destYAction = SKAction.moveToY(destY, duration: 0.1)
self.circleNode.runAction(destXAction)
self.circleNode.runAction(destYAction)
}
}
Any idea where I could be going wrong?
The main issue here I will assume is that the Game Scene never actually gets its size set.
In your GameViewController add the line scene.size = skView.bounds.size under let skView = self.view as! SKView.
This will set the size of your scene correctly, so now your ball should collide with your screen edge.... however when you set your circleNode path, the way you implemented it with offset your circle by half the size of the screen.
If you want your circle to appear in the middle of the screen, a better solution would be to have circleNode = SKShapeNode(circleOfRadius: 20) instead of circleNode.path = UIBezierPath(arcCenter: CGPoint(x: CGRectGetMidX(self.frame), y: CGRectGetMidY(self.frame)), radius: 20, startAngle: 0, endAngle: CGFloat(2*M_PI), clockwise: true).CGPath. Then set it to be in the center with circleNode.position = CGPointMake(frame.width/2, frame.height/2).
This should solve your issue as a whole, however something you may notice is that the ball may disappear through the edge the screen. To fix this, change your update method to something like this
func clamp(value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
if value > max {
return max
}
if value < min {
return min
}
return value
}
override func update(currentTime: NSTimeInterval) {
let ballRadius: CGFloat = 10
destX = clamp(destX, min: ballRadius, max: frame.width - ballRadius)
destY = clamp(destY, min: ballRadius, max: frame.height - ballRadius)
let destXAction = SKAction.moveToX(destX, duration: 0.1)
let destYAction = SKAction.moveToY(destY, duration: 0.1)
self.circleNode.runAction(destXAction)
self.circleNode.runAction(destYAction)
}
This will make it so the ball should never try to go outside the bounds of the screen. I would also suggest adding circleNode.physicsBody?.usesPreciseCollisionDetection = true so your collisions are more accurate.

UIBezierPath not updated as expected

This is my code for my custom view:
class CircleView3: UIView {
let endPoint = 270.0
var index = 0.0
var startPoint: Double {
get{
index++
let div = 360.0/60.0
let vaule = div * index
return 290.0 + vaule
}
}
func degreesToRadians (number: Double) -> CGFloat {
return CGFloat(number) * CGFloat(M_PI) / 180.0
}
override func drawRect(rect: CGRect) {
super.drawRect(rect)
let startAngleRadiant: CGFloat = degreesToRadians(startPoint)
let endAngleRadiant: CGFloat = degreesToRadians(endPoint)
print("start = \(startAngleRadiant)")
print("end = \(endAngleRadiant)")
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let path = UIBezierPath()
path.moveToPoint(center)
path.addArcWithCenter(center, radius: self.frame.height/2.2, startAngle: startAngleRadiant, endAngle: endAngleRadiant, clockwise: true)
path.addLineToPoint(center)
let strokeColor: UIColor = UIColor(red: 155.0/255.0, green: 137.0/255.0, blue: 22.0/255.0, alpha: 1.0)
strokeColor.setFill() //strokeColor.setStroke()
path.fill()
path.stroke()
}
}
I am drawing a circle. I call the setNeedsDisplay function from a view controller each second. I am supposed to draw a circle that is reducing its start and end point each second, but the circle keep its last line (position) even after the update. Plus the circle is not filled with my color, just the line is filled.
http://www.mediafire.com/watch/xnkew5eu8da5wub/IMG_0066.MOV
Update
I print the start values in drawRect, and the values are correct:
start = 293.6
start = 297.2
start = 300.8
start = 304.4
start = 308.0
start = 311.6
start = 315.2
start = 318.8
start = 322.4
start = 326.0
start = 329.6
start = 333.2
start = 336.8
start = 340.4
of course these are the values before changing them to radians.
Update 2
After I call the strokeColor.setFill() the situation is better, but I still see the problem. Please see this new video:
http://www.mediafire.com/watch/nb1b3v2zgqletwb/IMG_0069.MOV
For what its worth, I put your code in a project and I see a shrinking circle as setNeedsDisplay is called on the customView. It does not draw like your video.
Here is my additional code:
class ViewController: UIViewController {
#IBOutlet weak var circleView: CircleView3!
override func viewDidLoad() {
super.viewDidLoad()
NSTimer.scheduledTimerWithTimeInterval(0.25, target: self, selector: "updateCircle", userInfo: nil, repeats: true)
}
func updateCircle() {
circleView.setNeedsDisplay()
}
}

How to create a circular progress indicator for a count down timer

I am trying to add a countdown to my app and I'm having problems animating this. The look I'm going for is something similar to the iPad countdown shown below, with the red bar increasing as the clock counts down.
Initially I created an image atlas with an individual image for each half second of the countdown but this seems to take a lot of memory to run and therefore crashes the app so I now have to find an alternative.
I've been looking at colorizing here https://developer.apple.com/library/prerelease/ios/documentation/GraphicsAnimation/Conceptual/SpriteKit_PG/Sprites/Sprites.html#//apple_ref/doc/uid/TP40013043-CH9 but can't see a way of colorizing a section of the sprite, it seems that the whole sprite would be changed.
Does anyone know if colorizing would be an option here, or is there a way of reduce the memory used by an image atlas?
Thanks
You can do it using CAShapeLayer and animating the stroke end as follow:
update Xcode 9 • Swift 4
define the time left
let timeLeftShapeLayer = CAShapeLayer()
let bgShapeLayer = CAShapeLayer()
var timeLeft: TimeInterval = 60
var endTime: Date?
var timeLabel = UILabel()
var timer = Timer()
// here you create your basic animation object to animate the strokeEnd
let strokeIt = CABasicAnimation(keyPath: "strokeEnd")
define a method to create de UIBezierPath
startAngle at -90˚ and endAngle 270
func drawBgShape() {
bgShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
bgShapeLayer.strokeColor = UIColor.white.cgColor
bgShapeLayer.fillColor = UIColor.clear.cgColor
bgShapeLayer.lineWidth = 15
view.layer.addSublayer(bgShapeLayer)
}
func drawTimeLeftShape() {
timeLeftShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
timeLeftShapeLayer.strokeColor = UIColor.red.cgColor
timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
timeLeftShapeLayer.lineWidth = 15
view.layer.addSublayer(timeLeftShapeLayer)
}
add your Label
func addTimeLabel() {
timeLabel = UILabel(frame: CGRect(x: view.frame.midX-50 ,y: view.frame.midY-25, width: 100, height: 50))
timeLabel.textAlignment = .center
timeLabel.text = timeLeft.time
view.addSubview(timeLabel)
}
at viewDidload set the endTime and add your CAShapeLayer to your view:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.94, alpha: 1.0)
drawBgShape()
drawTimeLeftShape()
addTimeLabel()
// here you define the fromValue, toValue and duration of your animation
strokeIt.fromValue = 0
strokeIt.toValue = 1
strokeIt.duration = timeLeft
// add the animation to your timeLeftShapeLayer
timeLeftShapeLayer.add(strokeIt, forKey: nil)
// define the future end time by adding the timeLeft to now Date()
endTime = Date().addingTimeInterval(timeLeft)
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)
}
when updating the time
#objc func updateTime() {
if timeLeft > 0 {
timeLeft = endTime?.timeIntervalSinceNow ?? 0
timeLabel.text = timeLeft.time
} else {
timeLabel.text = "00:00"
timer.invalidate()
}
}
you can use this extension to convert the degrees to radians and display time
extension TimeInterval {
var time: String {
return String(format:"%02d:%02d", Int(self/60), Int(ceil(truncatingRemainder(dividingBy: 60))) )
}
}
extension Int {
var degreesToRadians : CGFloat {
return CGFloat(self) * .pi / 180
}
}
Sample project
Great piece of code and answer! Just thought I'd suggest the following. I was getting the time showing as 1:00 and then 0:60 which I think might be due to the "ceil" part of the code.
I changed it to the following (also wanting times less than 60 to show differently) and it seems to have removed the overlap of times.
For what it's worth ...
extension NSTimeInterval {
var time:String {
if self < 60 {
return String(format: "%02d", Int(floor(self%60)))
} else
{
return String(format: "%01d:%02d", Int(self/60), Int(floor(self%60)))
}
}
}
Thanks for the great answer from #Leo Dabus, and I see someone asking why the code didn't work / not able to implement on their own project. Here are some tips to keep in mind when using CABasicAnimation:
We should add the animation to the layer that ALREADY exists in the view, i.e. ensure calling layer.add after view.layer.addSublayer.
Try not to do animation in viewDidLoad, as we can't tell if the layer already exists.
So we can try something like:
override func viewDidLoad() {
super.viewDidLoad()
drawBgShape()
drawTimeLeftShape()
...
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// move the setup of animation here
strokeIt.fromValue = 0
strokeIt.toValue = 1
strokeIt.duration = timeLeft
timeLeftShapeLayer.add(strokeIt, forKey: nil)
}
Attached some great reference where help me to make the idea clear. Hope this can help and have a nice day ;)
https://stackoverflow.com/a/13066094/11207700
https://stackoverflow.com/a/43006847/11207700

Resources