I found it tricky to animate a UIImageView between two states: its original rectangle frame, and a new shape created with a UIBezierPath. There are many different techniques mentioned, most of which did not work for me.
First was the realization that using UIView block animation would not work; evidently one can't perform sublayer animations in an animateWithDuration: block. (see here and here)
That left CAAnimation, with the concrete subclasses like CABasicAnimation. I soon realized that one can't animate from a view that doesn't have a CAShapeLayer to one that does (see here, for example).
And they can't be just any two shape layer paths, but rather "Animating the path of a shape layer is only guaranteed to work when you are animating from like to like" (see here)
With that in place, comes the more mundane problems, like what to use for fromValue and toValue (should they be a CAShapeLayer, or a CGPath?), what to add the animation to (the layer, or the mask?), etc.
It seemed there were so many variables; which combination would give me the animation I was looking for?
The first important point is to construct the two bezier paths similarly, so the rectangle is a (trivial) analogue to the more complex shape.
// the complex bezier path
let initialPoint = CGPoint(x: 0, y: 0)
let curveStart = CGPoint(x: 0, y: (rect.size.height) * (0.2))
let curveControl = CGPoint(x: (rect.size.width) * (0.6), y: (rect.size.height) * (0.5))
let curveEnd = CGPoint(x: 0, y: (rect.size.height) * (0.8))
let firstCorner = CGPoint(x: 0, y: rect.size.height)
let secondCorner = CGPoint(x: rect.size.width, y: rect.size.height)
let thirdCorner = CGPoint(x: rect.size.width, y: 0)
var myBezierArc = UIBezierPath()
myBezierArc.moveToPoint(initialPoint)
myBezierArc.addLineToPoint(curveStart)
myBezierArc.addQuadCurveToPoint(curveEnd, controlPoint: curveControl)
myBezierArc.addLineToPoint(firstCorner)
myBezierArc.addLineToPoint(secondCorner)
myBezierArc.addLineToPoint(thirdCorner)
The simpler 'trivial' bezier path, that creates a rectangle, is exactly the same but the controlPoint is set so that it appears to not be there:
let curveControl = CGPoint(x: 0, y: (rect.size.height) * (0.5))
( Try removing the addQuadCurveToPoint line to get a very strange animation! )
And finally, the animation commands:
let myAnimation = CABasicAnimation(keyPath: "path")
if (isArcVisible == true) {
myAnimation.fromValue = myBezierArc.CGPath
myAnimation.toValue = myBezierTrivial.CGPath
} else {
myAnimation.fromValue = myBezierTrivial.CGPath
myAnimation.toValue = myBezierArc.CGPath
}
myAnimation.duration = 0.4
myAnimation.fillMode = kCAFillModeForwards
myAnimation.removedOnCompletion = false
myImageView.layer.mask.addAnimation(myAnimation, forKey: "animatePath")
If anyone is interested, the project is here.
Another approach is to use a display link. It's like a timer, except it's coordinated with the update of the display. You then have the handler of the display link modify the view according to what it should look like at any particular point of the animation.
For example, if you wanted to animate the rounding of the corners of the mask from 0 to 50 points, you could do something like the following, where percent is a value between 0.0 and 1.0 indicating what percentage of the animation is done:
let path = UIBezierPath(rect: imageView.bounds)
let mask = CAShapeLayer()
mask.path = path.CGPath
imageView.layer.mask = mask
let animation = AnimationDisplayLink(duration: 0.5) { percent in
let cornerRadius = percent * 50.0
let path = UIBezierPath(roundedRect: self.imageView.bounds, cornerRadius: cornerRadius)
mask.path = path.CGPath
}
Where:
class AnimationDisplayLink : NSObject {
var animationDuration: CGFloat
var animationHandler: (percent: CGFloat) -> ()
var completionHandler: (() -> ())?
private var startTime: CFAbsoluteTime!
private var displayLink: CADisplayLink!
init(duration: CGFloat, animationHandler: (percent: CGFloat)->(), completionHandler: (()->())? = nil) {
animationDuration = duration
self.animationHandler = animationHandler
self.completionHandler = completionHandler
super.init()
startDisplayLink()
}
private func startDisplayLink () {
startTime = CFAbsoluteTimeGetCurrent()
displayLink = CADisplayLink(target: self, selector: "handleDisplayLink:")
displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes)
}
private func stopDisplayLink() {
displayLink.invalidate()
displayLink = nil
}
func handleDisplayLink(displayLink: CADisplayLink) {
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
var percent = CGFloat(elapsed) / animationDuration
if percent >= 1.0 {
stopDisplayLink()
animationHandler(percent: 1.0)
completionHandler?()
} else {
animationHandler(percent: percent)
}
}
}
The virtue of the display link approach is that it can be used to animate some property that is otherwise unanimatable. It also lets you to precisely dictate the interim state during the animation.
If you can use CAAnimation or UIKit block-based animation, that's probably the way to go. But the display link can sometimes be a good fallback approach.
I was inspired by your example to try a circle to square animation using the techniques that are mentioned in your answer and some of the links. I intend to extend this to be a more general circle to polygon animation, but currently it only works for squares. I have a class called RDPolyCircle (a subclass of CAShapeLayer) that does the heavy lifting. Here is its code,
#interface RDPolyCircle ()
#property (strong,nonatomic) UIBezierPath *polyPath;
#property (strong,nonatomic) UIBezierPath *circlePath;
#end
#implementation RDPolyCircle {
double cpDelta;
double cosR;
}
-(instancetype)initWithFrame:(CGRect) frame numberOfSides:(NSInteger)sides isPointUp:(BOOL) isUp isInitiallyCircle:(BOOL) isCircle {
if (self = [super init]) {
self.frame = frame;
_isPointUp = isUp;
_isExpandedPolygon = !isCircle;
double radius = (frame.size.width/2.0);
cosR = sin(45 * M_PI/180.0) * radius;
double fractionAlongTangent = 4.0*(sqrt(2)-1)/3.0;
cpDelta = fractionAlongTangent * radius * sin(45 * M_PI/180.0);
_circlePath = [self createCirclePathForFrame:frame];
_polyPath = [self createPolygonPathForFrame:frame numberOfSides:sides];
self.path = (isCircle)? self.circlePath.CGPath : self.polyPath.CGPath;
self.fillColor = [UIColor clearColor].CGColor;
self.strokeColor = [UIColor blueColor].CGColor;
self.lineWidth = 6.0;
}
return self;
}
-(UIBezierPath *)createCirclePathForFrame:(CGRect) frame {
CGPoint ctr = CGPointMake(frame.origin.x + frame.size.width/2.0, frame.origin.y + frame.size.height/2.0);
// create a circle using 4 arcs, with the first one symmetrically spanning the y-axis
CGPoint leftUpper = CGPointMake(ctr.x - cosR, ctr.y - cosR);
CGPoint cp1 = CGPointMake(leftUpper.x + cpDelta, leftUpper.y - cpDelta);
CGPoint rightUpper = CGPointMake(ctr.x + cosR, ctr.y - cosR);
CGPoint cp2 = CGPointMake(rightUpper.x - cpDelta, rightUpper.y - cpDelta);
CGPoint cp3 = CGPointMake(rightUpper.x + cpDelta, rightUpper.y + cpDelta);
CGPoint rightLower = CGPointMake(ctr.x + cosR, ctr.y + cosR);
CGPoint cp4 = CGPointMake(rightLower.x + cpDelta, rightLower.y - cpDelta);
CGPoint cp5 = CGPointMake(rightLower.x - cpDelta, rightLower.y + cpDelta);
CGPoint leftLower = CGPointMake(ctr.x - cosR, ctr.y + cosR);
CGPoint cp6 = CGPointMake(leftLower.x + cpDelta, leftLower.y + cpDelta);
CGPoint cp7 = CGPointMake(leftLower.x - cpDelta, leftLower.y - cpDelta);
CGPoint cp8 = CGPointMake(leftUpper.x - cpDelta, leftUpper.y + cpDelta);
UIBezierPath *circle = [UIBezierPath bezierPath];
[circle moveToPoint:leftUpper];
[circle addCurveToPoint:rightUpper controlPoint1:cp1 controlPoint2:cp2];
[circle addCurveToPoint:rightLower controlPoint1:cp3 controlPoint2:cp4];
[circle addCurveToPoint:leftLower controlPoint1:cp5 controlPoint2:cp6];
[circle addCurveToPoint:leftUpper controlPoint1:cp7 controlPoint2:cp8];
[circle closePath];
circle.lineCapStyle = kCGLineCapRound;
return circle;
}
-(UIBezierPath *)createPolygonPathForFrame:(CGRect) frame numberOfSides:(NSInteger) sides {
CGPoint leftUpper = CGPointMake(self.frame.origin.x, self.frame.origin.y);
CGPoint cp1 = CGPointMake(leftUpper.x + cpDelta, leftUpper.y);
CGPoint rightUpper = CGPointMake(self.frame.origin.x + self.frame.size.width, self.frame.origin.y);
CGPoint cp2 = CGPointMake(rightUpper.x - cpDelta, rightUpper.y);
CGPoint cp3 = CGPointMake(rightUpper.x, rightUpper.y + cpDelta);
CGPoint rightLower = CGPointMake(self.frame.origin.x + self.frame.size.width, self.frame.origin.y + self.frame.size.height);
CGPoint cp4 = CGPointMake(rightLower.x , rightLower.y - cpDelta);
CGPoint cp5 = CGPointMake(rightLower.x - cpDelta, rightLower.y);
CGPoint leftLower = CGPointMake(self.frame.origin.x, self.frame.origin.y + self.frame.size.height);
CGPoint cp6 = CGPointMake(leftLower.x + cpDelta, leftLower.y);
CGPoint cp7 = CGPointMake(leftLower.x, leftLower.y - cpDelta);
CGPoint cp8 = CGPointMake(leftUpper.x, leftUpper.y + cpDelta);
UIBezierPath *square = [UIBezierPath bezierPath];
[square moveToPoint:leftUpper];
[square addCurveToPoint:rightUpper controlPoint1:cp1 controlPoint2:cp2];
[square addCurveToPoint:rightLower controlPoint1:cp3 controlPoint2:cp4];
[square addCurveToPoint:leftLower controlPoint1:cp5 controlPoint2:cp6];
[square addCurveToPoint:leftUpper controlPoint1:cp7 controlPoint2:cp8];
[square closePath];
square.lineCapStyle = kCGLineCapRound;
return square;
}
-(void)toggleShape {
if (self.isExpandedPolygon) {
[self restore];
}else{
CABasicAnimation *expansionAnimation = [CABasicAnimation animationWithKeyPath:#"path"];
expansionAnimation.fromValue = (__bridge id)(self.circlePath.CGPath);
expansionAnimation.toValue = (__bridge id)(self.polyPath.CGPath);
expansionAnimation.duration = 0.5;
expansionAnimation.fillMode = kCAFillModeForwards;
expansionAnimation.removedOnCompletion = NO;
[self addAnimation:expansionAnimation forKey:#"Expansion"];
self.isExpandedPolygon = YES;
}
}
-(void)restore {
CABasicAnimation *contractionAnimation = [CABasicAnimation animationWithKeyPath:#"path"];
contractionAnimation.fromValue = (__bridge id)(self.polyPath.CGPath);
contractionAnimation.toValue = (__bridge id)(self.circlePath.CGPath);
contractionAnimation.duration = 0.5;
contractionAnimation.fillMode = kCAFillModeForwards;
contractionAnimation.removedOnCompletion = NO;
[self addAnimation:contractionAnimation forKey:#"Contraction"];
self.isExpandedPolygon = NO;
}
From the view controller, I create an instance of this layer, and add it to a simple view's layer, then do the animations on a button push,
#import "ViewController.h"
#import "RDPolyCircle.h"
#interface ViewController ()
#property (weak, nonatomic) IBOutlet UIView *circleView; // a plain UIView 150 x 150 centered in the superview
#property (strong,nonatomic) RDPolyCircle *shapeLayer;
#end
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// currently isPointUp and numberOfSides are not implemented (the shape created has numberOfSides=4 and isPointUp=NO)
// isInitiallyCircle is implemented
self.shapeLayer = [[RDPolyCircle alloc] initWithFrame:self.circleView.bounds numberOfSides: 4 isPointUp:NO isInitiallyCircle:YES];
[self.circleView.layer addSublayer:self.shapeLayer];
}
- (IBAction)toggleShape:(UIButton *)sender {
[self.shapeLayer toggleShape];
}
The project can be found here, http://jmp.sh/iK3kuVs.
Related
Given two UIViews with borders that have UIPanGestureRecognizers attached to them:
If I drag the UIView on the left over the UIView on the right, this is the usual behavior:
Is it possible to get them to do the behavior below where it looks like they merge?:
Looking for the simplest way possible to do this!
One way is to use multiple sibling layers and zPosition. To achieve the effect you add two layers, one for border, one for content. And the border layer has a smaller zPosition than the content. And, of course, move the layers with the UIPanGestureRecognizer.
MP4 version
Swift:
import UIKit
class MergingView: UIView {
let borderLayer = CALayer()
let backgroundLayer = CALayer()
override func layoutSubviews() {
super.layoutSubviews()
addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))))
borderLayer.borderWidth = 5
borderLayer.frame = frame
borderLayer.zPosition = 10
borderLayer.borderColor = UIColor.black.cgColor
superview?.layer.addSublayer(borderLayer)
backgroundLayer.frame = CGRect(x: frame.origin.x + 5, y: frame.origin.y + 5, width: frame.width - 10, height: frame.height - 10)
backgroundLayer.zPosition = 20
backgroundLayer.backgroundColor = UIColor.white.cgColor
superview?.layer.addSublayer(backgroundLayer);
}
#objc func handlePan(_ recognizer: UIPanGestureRecognizer) {
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
let translation = recognizer.translation(in: self)
frame = self.frame.offsetBy(dx: translation.x, dy: translation.y)
recognizer.setTranslation(CGPoint.zero, in: self)
borderLayer.frame = borderLayer.frame.offsetBy(dx: translation.x, dy: translation.y)
backgroundLayer.frame = backgroundLayer.frame.offsetBy(dx: translation.x, dy: translation.y)
CATransaction.commit()
}
}
Objective-C header:
#import <UIKit/UIKit.h>
#interface MVMergingView : UIView
#end
Objective-C implementation:
#import "MVMergingView.h"
#interface MVMergingView ()
#property (strong) CALayer *borderLayer;
#property (strong) CALayer *backgroundLayer;
#end
#implementation MVMergingView
- (void)layoutSubviews {
[super layoutSubviews];
[self addGestureRecognizer:[[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(handlePan:)]];
CALayer *borderLayer = [CALayer layer];
borderLayer.borderWidth = 5.f;
borderLayer.frame = self.frame;
borderLayer.zPosition = 10;
borderLayer.borderColor = UIColor.blackColor.CGColor;
self.borderLayer = borderLayer;
[self.superview.layer addSublayer:borderLayer];
CALayer *backgroundLayer = [CALayer layer];
backgroundLayer.frame = CGRectMake(self.frame.origin.x + 5.f, self.frame.origin.y + 5.f, self.frame.size.width - 10, self.frame.size.height - 10);
backgroundLayer.zPosition = 20;
backgroundLayer.backgroundColor = UIColor.whiteColor.CGColor;
self.backgroundLayer = backgroundLayer;
[self.superview.layer addSublayer:backgroundLayer];
}
- (void)handlePan:(UIPanGestureRecognizer *)recognizer {
[CATransaction begin];
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
CGPoint translation = [recognizer translationInView:self];
self.frame = CGRectOffset(self.frame, translation.x, translation.y);
[recognizer setTranslation:CGPointZero inView:self];
self.borderLayer.frame = CGRectOffset(self.borderLayer.frame, translation.x, translation.y);
self.backgroundLayer.frame = CGRectOffset(self.backgroundLayer.frame, translation.x, translation.y);
[CATransaction commit];
}
#end
Example repo: https://github.com/dimitarnestorov/MergingView
It seems like your requirement to "merge" rather than overlap exists only because of the borders. If there were no borders, then there's no need for any merging; you simply overlap them. So the question becomes how to deal with the borders.
I'm thinking perhaps you can have a special UIView subclass (MergingView?) which would act as a parent of 2 views; a border view and a content view. Now that the border and content are in separate views, "merging" becomes trivial.
When we drop A on top of B, all we have to do is give A's border view and content view to B (so they are now subviews of B), and then send all of B's border views to the back.
You can then proceed to merge more with B, the procedure is the same.
Would this suit your use case? Happy to post code if needed.
I'm trying to create a sort of "radar" that would look like this :
The idea is that the blue part will act like a compass to indicate proximity of stuff to the user. The shape would be narrow, long and blue when the user is far from the objective, and become shorter, thicker and red when he gets closer, as shown below:
To achieve this, I drew a few circles (actually I tried IBDesignable and IBInspectable but I keep getting "Build failed" but that is another subject)
My question is : Is this possible to draw the "cone" part that is supposed to change its dimensions at a high rate without risking lag ?
Can I use UIBezierPath / AddArc or Addline methods to achieve this or I am heading to a dead end ?
There are two approaches:
Define complex bezier shapes and animate use CABasicAnimation:
- (UIBezierPath *)pathWithCenter:(CGPoint)center angle:(CGFloat)angle radius:(CGFloat)radius {
UIBezierPath *path = [UIBezierPath bezierPath];
CGPoint point1 = CGPointMake(center.x - radius * sinf(angle), center.y - radius * cosf(angle));
[path moveToPoint:center];
[path addLineToPoint:point1];
NSInteger curves = 8;
CGFloat currentAngle = M_PI_2 * 3.0 - angle;
for (NSInteger i = 0; i < curves; i++) {
CGFloat nextAngle = currentAngle + angle * 2.0 / curves;
CGPoint point2 = CGPointMake(center.x + radius * cosf(nextAngle), center.y + radius * sinf(nextAngle));
CGFloat controlPointRadius = radius / cosf(angle / curves);
CGPoint controlPoint = CGPointMake(center.x + controlPointRadius * cosf(currentAngle + angle / curves), center.y + controlPointRadius * sinf(currentAngle + angle / curves));
[path addQuadCurveToPoint:point2 controlPoint:controlPoint];
currentAngle = nextAngle;
point1 = point2;
}
[path closePath];
return path;
}
I just split the arc into a series of quad curves. I find cubic curves can get closer, but with just a few quad curves, we get a very good approximation.
And then animate it with CABasicAnimation:
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
CAShapeLayer *layer = [CAShapeLayer layer];
layer.fillColor = self.startColor.CGColor;
layer.path = self.startPath.CGPath;
[self.view.layer addSublayer:layer];
self.radarLayer = layer;
}
- (IBAction)didTapButton:(id)sender {
self.radarLayer.path = self.finalPath.CGPath;
CABasicAnimation *pathAnimation = [CABasicAnimation animationWithKeyPath:#"path"];
pathAnimation.fromValue = (id)self.startPath.CGPath;
pathAnimation.toValue = (id)self.finalPath.CGPath;
pathAnimation.removedOnCompletion = false;
self.radarLayer.fillColor = self.finalColor.CGColor;
CABasicAnimation *fillAnimation = [CABasicAnimation animationWithKeyPath:#"fillColor"];
fillAnimation.fromValue = (id)self.startColor.CGColor;
fillAnimation.toValue = (id)self.finalColor.CGColor;
fillAnimation.removedOnCompletion = false;
CAAnimationGroup *group = [CAAnimationGroup animation];
group.animations = #[pathAnimation, fillAnimation];
group.duration = 2.0;
[self.radarLayer addAnimation:group forKey:#"bothOfMyAnimations"];
}
That yields:
The other approach is to use simple bezier, but animate with display link:
If you wanted to use a CAShapeLayer and a CADisplayLink (which is like a timer that's optimized for screen updates), you could do something like:
#interface ViewController ()
#property (nonatomic, strong) CADisplayLink *displayLink;
#property (nonatomic) CFAbsoluteTime startTime;
#property (nonatomic) CFAbsoluteTime duration;
#property (nonatomic, weak) CAShapeLayer *radarLayer;
#end
#implementation ViewController
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
CAShapeLayer *layer = [CAShapeLayer layer];
[self.view.layer addSublayer:layer];
self.radarLayer = layer;
[self updateRadarLayer:layer percent:0.0];
}
- (IBAction)didTapButton:(id)sender {
[self startDisplayLink];
}
- (void)updateRadarLayer:(CAShapeLayer *)radarLayer percent:(CGFloat)percent {
CGFloat angle = M_PI_4 * (1.0 - percent / 2.0);
CGFloat distance = 200 * (0.5 + percent / 2.0);
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:self.view.center];
[path addArcWithCenter:self.view.center radius:distance startAngle:M_PI_2 * 3.0 - angle endAngle:M_PI_2 * 3.0 + angle clockwise:true];
[path closePath];
radarLayer.path = path.CGPath;
radarLayer.fillColor = [self colorForPercent:percent].CGColor;
}
- (UIColor *)colorForPercent:(CGFloat)percent {
return [UIColor colorWithRed:percent green:0 blue:1.0 - percent alpha:1];
}
- (void)startDisplayLink {
self.startTime = CFAbsoluteTimeGetCurrent();
self.duration = 2.0;
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:#selector(handleDisplayLink:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)handleDisplayLink:(CADisplayLink *)link {
CGFloat percent = (CFAbsoluteTimeGetCurrent() - self.startTime) / self.duration;
if (percent < 1.0) {
[self updateRadarLayer:self.radarLayer percent:percent];
} else {
[self stopDisplayLink];
[self updateRadarLayer:self.radarLayer percent:1.0];
}
}
- (void)stopDisplayLink {
[self.displayLink invalidate];
self.displayLink = nil;
}
That yields:
You should probably use one or more CAShapeLayer objects and Core Animation.
You can install the CGPath from a UIBezierPath into a shape layer, and you can animate the changes to the shape layers, although to get the animation to work right you want to have the same number and type of control points in the shape at the beginning and ending of your animation.
I know it is simple to create the effect making the moon circling around the earth in IOS. Suppose the moon is a CALayer object, just change the anchorPoint of this object to the earth then it will animate circling around the earth. But how to create the moon that rotate by itself at the same time? since the moon can only have one anchorPoint, seems I can not make this CALayer object rotate by itself anymore. What do you guys think? thanks.
You can cause the "moon" to revolve around a point by animating it along a bezier path, and at the same time animate a rotation transform. Here is a simple example,
#interface ViewController ()
#property (strong,nonatomic) UIButton *moon;
#property (strong,nonatomic) UIBezierPath *circlePath;
#end
#implementation ViewController
-(void)viewDidLoad {
self.moon = [UIButton buttonWithType:UIButtonTypeInfoDark];
[self.moon addTarget:self action:#selector(clickedCircleButton:) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:self.moon];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
CGRect circleRect = CGRectMake(60,100,200,200);
self.circlePath = [UIBezierPath bezierPathWithOvalInRect:circleRect];
self.moon.center = CGPointMake(circleRect.origin.x + circleRect.size.width, circleRect.origin.y + circleRect.size.height/2.0);
}
- (void)clickedCircleButton:(UIButton *)sender {
CAKeyframeAnimation *orbit = [CAKeyframeAnimation animationWithKeyPath:#"position"];
orbit.path = self.circlePath.CGPath;
orbit.calculationMode = kCAAnimationPaced;
orbit.duration = 4.0;
orbit.repeatCount = CGFLOAT_MAX;
[self.moon.layer addAnimation:orbit forKey:#"circleAnimation"];
CABasicAnimation *fullRotation = [CABasicAnimation animationWithKeyPath:#"transform.rotation.z"];
fullRotation.fromValue = 0;
fullRotation.byValue = #(2.0*M_PI);
fullRotation.duration = 4.0;
fullRotation.repeatCount = CGFLOAT_MAX;
[self.moon.layer addAnimation:fullRotation forKey:#"Rotate"];
}
These particular values will cause the "moon" to keep the same face toward the center like earth's moon does.
Use two layers.
One is an invisible "arm" reaching from the earth to the moon. It does a rotation transform around its anchor point, which is the center of the earth. This causes the moon, out at the end of the "arm", to revolve around the earth.
The other is the moon. It is a sublayer of the "arm", sitting out at the end of the arm. If you want it to rotate independently, rotate it round its anchor point, which is its own center.
(Be aware, however, that the real moon does not do this. For the real moon, the "arm" is sufficient, because the real moon rotates in sync with its own revolution around the earth - so that we see always the same face of the moon.)
I was looking for this kind of implementation myself.
I followed rdelmar answer and used this swift version:
class ViewController: UIViewController {
var circlePath: UIBezierPath!
var moon = UIButton(frame: CGRect(x: 10, y: 10, width: 50, height: 50))
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
func setup() {
let circleRect = CGRect(x: 60, y: 100, width: 200, height: 200)
self.circlePath = UIBezierPath(ovalIn: circleRect)
self.moon.center = CGPoint(x: circleRect.origin.x + circleRect.size.width, y: circleRect.origin.y + circleRect.size.height/2.0)
self.moon.addTarget(self, action: #selector(didTap), for: .touchUpInside)
moon.backgroundColor = .blue
self.view.addSubview(moon)
}
#objc func didTap() {
let orbit = CAKeyframeAnimation(keyPath: "position")
orbit.path = self.circlePath.cgPath
orbit.calculationMode = .paced
orbit.duration = 4.0
orbit.repeatCount = Float(CGFloat.greatestFiniteMagnitude)
moon.layer.add(orbit, forKey: "circleAnimation")
let fullRotation = CABasicAnimation(keyPath: "transform.rotation.z")
fullRotation.fromValue = 0
fullRotation.byValue = CGFloat.pi*2
fullRotation.duration = 4
fullRotation.repeatCount = Float(CGFloat.greatestFiniteMagnitude)
moon.layer.add(orbit, forKey: "Rotate")
}
}
I have created a simple audio player which plays a single audio. The views shows CAShapeLayer circular progress and also shows current time using CATextLayer. The figure below shows, the view:
Everything works fine until now, I can play, pause and the CAShapeLayer shows the progress. Now, I want to make it so that, when I touch the stroke (track) portion of the CAShapeLayer path, I would want to seek the player to that time. I tried few approaches but I could not detect touches on all parts of the stroke. It seems like the calculations I have done is not quite appropriate. I would be very happy if any body could help me with this.
Here is my complete code,
#interface ViewController ()
#property (nonatomic, weak) CAShapeLayer *progressLayer;
#property (nonatomic, weak) CAShapeLayer *trackLayer;
#property (nonatomic, weak) CATextLayer *textLayer;
#property (nonatomic, strong) AVAudioPlayer *audioPlayer;
#property (nonatomic, weak) NSTimer *audioPlayerTimer;
#end
#implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
[self prepareLayers];
[self prepareAudioPlayer];
[self prepareGestureRecognizers];
}
- (void)prepareLayers
{
CGFloat lineWidth = 40;
CGRect shapeRect = CGRectMake(0,
0,
CGRectGetWidth(self.view.bounds),
CGRectGetWidth(self.view.bounds));
CGRect actualRect = CGRectInset(shapeRect, lineWidth / 2.0, lineWidth / 2.0);
CGPoint center = CGPointMake(CGRectGetMidX(actualRect), CGRectGetMidY(actualRect));
CGFloat radius = CGRectGetWidth(actualRect) / 2.0;
UIBezierPath *track = [UIBezierPath bezierPathWithArcCenter:center
radius:radius
startAngle:0 endAngle:2 * M_PI
clockwise:true];
UIBezierPath *progressLayerPath = [UIBezierPath bezierPathWithArcCenter:center
radius:radius
startAngle:-M_PI_2
endAngle:2 * M_PI - M_PI_2
clockwise:true];
progressLayerPath.lineWidth = lineWidth;
CAShapeLayer *trackLayer = [CAShapeLayer layer];
trackLayer.contentsScale = [UIScreen mainScreen].scale;
trackLayer.shouldRasterize = YES;
trackLayer.rasterizationScale = [UIScreen mainScreen].scale;
trackLayer.bounds = actualRect;
trackLayer.strokeColor = [UIColor lightGrayColor].CGColor;
trackLayer.fillColor = [UIColor whiteColor].CGColor;
trackLayer.position = self.view.center;
trackLayer.lineWidth = lineWidth;
trackLayer.path = track.CGPath;
self.trackLayer = trackLayer;
CAShapeLayer *progressLayer = [CAShapeLayer layer];
progressLayer.contentsScale = [UIScreen mainScreen].scale;
progressLayer.shouldRasterize = YES;
progressLayer.rasterizationScale = [UIScreen mainScreen].scale;
progressLayer.masksToBounds = NO;
progressLayer.strokeEnd = 0;
progressLayer.bounds = actualRect;
progressLayer.fillColor = nil;
progressLayer.path = progressLayerPath.CGPath;
progressLayer.position = self.view.center;
progressLayer.lineWidth = lineWidth;
progressLayer.lineJoin = kCALineCapRound;
progressLayer.lineCap = kCALineCapRound;
progressLayer.strokeColor = [UIColor redColor].CGColor;
CATextLayer *textLayer = [CATextLayer layer];
textLayer.contentsScale = [UIScreen mainScreen].scale;
textLayer.shouldRasterize = YES;
textLayer.rasterizationScale = [UIScreen mainScreen].scale;
textLayer.font = (__bridge CTFontRef)[UIFont systemFontOfSize:30.0];
textLayer.position = self.view.center;
textLayer.bounds = CGRectMake(0, 0, 300, 100);
[self.view.layer addSublayer:trackLayer];
[self.view.layer addSublayer:progressLayer];
[self.view.layer addSublayer:textLayer];
self.trackLayer = trackLayer;
self.progressLayer = progressLayer;
self.textLayer = textLayer;
[self displayText:#"Play"];
}
- (void)prepareAudioPlayer
{
NSError *error;
self.audioPlayer = [[AVAudioPlayer alloc]
initWithContentsOfURL:[[NSBundle mainBundle] URLForResource:#"Song" withExtension:#"mp3"]
error:&error];
self.audioPlayer.volume = 0.2;
if (!self.audioPlayer) {
NSLog(#"Error occurred, could not create audio player");
return;
}
[self.audioPlayer prepareToPlay];
}
- (void)prepareGestureRecognizers
{
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self
action:#selector(playerViewTapped:)];
[self.view addGestureRecognizer:tap];
}
- (void)playerViewTapped:(UITapGestureRecognizer *)tap
{
CGPoint tappedPoint = [tap locationInView:self.view];
if ([self.view.layer hitTest:tappedPoint] == self.progressLayer) {
CGPoint locationInProgressLayer = [self.view.layer convertPoint:tappedPoint toLayer:self.progressLayer];
NSLog(#"Progress view tapped %#", NSStringFromCGPoint(locationInProgressLayer));
// this is called sometimes but not always when I tap the stroke
} else if ([self.view.layer hitTest:tappedPoint] == self.textLayer) {
if ([self.audioPlayer isPlaying]) {
[self.audioPlayerTimer invalidate];
[self displayText:#"Play"];
[self.audioPlayer pause];
} else {
[self.audioPlayer play];
self.audioPlayerTimer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:#selector(increaseProgress:)
userInfo:nil
repeats:YES];
}
}
}
- (void)increaseProgress:(NSTimer *)timer
{
NSTimeInterval currentTime = self.audioPlayer.currentTime;
NSTimeInterval totalDuration = self.audioPlayer.duration;
float progress = currentTime / totalDuration;
self.progressLayer.strokeEnd = progress;
int minute = ((int)currentTime) / 60;
int second = (int)currentTime % 60;
NSString *progressString = [NSString stringWithFormat:#"%02d : %02d ", minute,second];
[self displayText:progressString];
}
- (void)displayText:(NSString *)text
{
UIColor *redColor = [UIColor redColor];
UIFont *font = [UIFont fontWithName:#"HelveticaNeue" size:70];
NSDictionary *attribtues = #{
NSForegroundColorAttributeName: redColor,
NSFontAttributeName: font,
};
NSAttributedString *progressAttrString = [[NSAttributedString alloc] initWithString:text
attributes:attribtues];
self.textLayer.alignmentMode = kCAAlignmentCenter;
self.textLayer.string = progressAttrString;
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
{
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
void(^animationBlock)(id<UIViewControllerTransitionCoordinatorContext>) =
^(id<UIViewControllerTransitionCoordinatorContext> context) {
CGRect rect = (CGRect){.origin = CGPointZero, .size = size};
CGPoint center = CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect));
self.progressLayer.position = center;
self.textLayer.position = center;
self.trackLayer.position = center;
};
[coordinator animateAlongsideTransitionInView:self.view
animation:animationBlock completion:nil];
}
#end
As far as I know the CALAyer hitTest method does a very primitive bounds check. If the point is inside the layer's bounds, it returns YES, otherwise it returns NO.
CAShapeLayer doesn't make any attempt to tell if the point intersects the shape layer's path.
UIBezierPath does have a hitTest method, but I'm pretty sure that detects touches in the interior of a closed path, and would not work with a thick line arc line the one you're using.
If you want to hit test using paths I think you're going to have to roll your own hitTest logic that builds a UIBezierPath that is a closed path defining the shape you are testing for (your thick arc line). Doing that for an animation that's in-flight might be too slow.
Another problem you'll face: You're interrogating a layer that has an in-flight animation. Even when you're simply animating the center property of a layer that doesn't work. Instead you have to interrogate the layer's presentationLayer, which is a layer that approximates the settings of an in-flight animation.
I think your best solution will be to take the starting and ending position of your arc, convert the start-to end value to starting and ending angles, use trig to convert your touch position to an angle and radius, and see if the touch position is in the range of the start-to-end touch and within the inner and outer radius of the indicator line. That would just require some basic trig.
Edit:
Hint: The trig function you want to use to convert x and y position to an angle is arc tangent. arc tangent takes an X and a Y value and gives back an angle. However, to use the arc tangent properly you need to implement a bunch of logic to figure out what quadrant of the circle your point is in.In C, the math library function you want is atan2(). The atan2() function takes care of everything for you. You'll convert your point so 0,0 is in the center, and atan2() will give you the angle along the circle. To calculate the distance from the center of the circle you use the distance formula, a² + b² = c².
i am writing an App where i use UIKit Dynamics to simulate the interactions of different circles with one another.
I create my circles with the following code:
self = [super initWithFrame:CGRectMake(location.x - radius/2.0, location.y - radius/2, radius, radius)];
if (self) {
[self.layer setCornerRadius: radius /2.0f];
self.clipsToBounds = YES;
self.layer.masksToBounds = YES;
self.backgroundColor = color;
self.userInteractionEnabled = NO;
}
return self;
where location represents the desired location of the circle, and radius its radius.
I then add these circles to different UIBehaviours, by doing:
[_collision addItem:circle];
[_gravity addItem:circle];
[_itemBehaviour addItem:circle];
The itemBaviour is defined as follows:
_itemBehaviour = [[UIDynamicItemBehavior alloc] initWithItems:#[square]];
_itemBehaviour.elasticity = 1;
_itemBehaviour.friction = 0;
_itemBehaviour.resistance = 0;
_itemBehaviour.angularResistance = 0;
_itemBehaviour.allowsRotation = NO;
The problem i am having, is that my circles are behaving as squares. When hit in certain ways they gain angular momentum and lose speed. If they collide again, sometimes the angular momentum is again reverted to speed. This looks normal for squares, but when the view is round, like in my case, this behaviour looks weird and unnatural.
Turning on some debug options, i made this screenshot:
As you can see, the circle is appearently a square.
So my question is, how can i create an UIVIew that is truly a circle and will behave as such in UIKit Dynamics?
I know this question predated iOS 9, but for the benefit of future readers, you can now define a view with collisionBoundsType of UIDynamicItemCollisionBoundsTypePath and a circular collisionBoundingPath.
So, while you cannot "create an UIView that is truly a circle", you can define a path that defines both the shape that is rendered inside the view as well as the collision boundaries for the animator, yielding an effect of a round view (even though the view, itself, is obviously still rectangular, as all views are):
#interface CircleView: UIView
#property (nonatomic) CGFloat lineWidth;
#property (nonatomic, strong) CAShapeLayer *shapeLayer;
#end
#implementation CircleView
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self configure];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self configure];
}
return self;
}
- (instancetype)init {
return [self initWithFrame:CGRectZero];
}
- (void)configure {
self.translatesAutoresizingMaskIntoConstraints = false;
// create shape layer for circle
self.shapeLayer = [CAShapeLayer layer];
self.shapeLayer.strokeColor = [[UIColor blueColor] CGColor];
self.shapeLayer.fillColor = [[[UIColor blueColor] colorWithAlphaComponent:0.5] CGColor];
self.lineWidth = 3;
[self.layer addSublayer:self.shapeLayer];
}
- (void)layoutSubviews {
[super layoutSubviews];
// path of shape layer is with respect to center of the `bounds`
CGPoint center = CGPointMake(self.bounds.origin.x + self.bounds.size.width / 2, self.bounds.origin.y + self.bounds.size.height / 2);
self.shapeLayer.path = [[self circularPathWithLineWidth:self.lineWidth center:center] CGPath];
}
- (UIDynamicItemCollisionBoundsType)collisionBoundsType {
return UIDynamicItemCollisionBoundsTypePath;
}
- (UIBezierPath *)collisionBoundingPath {
// path of collision bounding path is with respect to center of the dynamic item, so center of this path will be CGPointZero
return [self circularPathWithLineWidth:0 center:CGPointZero];
}
- (UIBezierPath *)circularPathWithLineWidth:(CGFloat)lineWidth center:(CGPoint)center {
CGFloat radius = (MIN(self.bounds.size.width, self.bounds.size.height) - self.lineWidth) / 2;
return [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:0 endAngle:M_PI * 2 clockwise:true];
}
#end
Then, when you do your collision, it will honor the collisionBoundingPath values:
self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];
// create circle views
CircleView *circle1 = [[CircleView alloc] initWithFrame:CGRectMake(60, 100, 80, 80)];
[self.view addSubview:circle1];
CircleView *circle2 = [[CircleView alloc] initWithFrame:CGRectMake(250, 150, 120, 120)];
[self.view addSubview:circle2];
// have them collide with each other
UICollisionBehavior *collision = [[UICollisionBehavior alloc] initWithItems:#[circle1, circle2]];
[self.animator addBehavior:collision];
// with perfect elasticity
UIDynamicItemBehavior *behavior = [[UIDynamicItemBehavior alloc] initWithItems:#[circle1, circle2]];
behavior.elasticity = 1;
[self.animator addBehavior:behavior];
// and push one of the circles
UIPushBehavior *push = [[UIPushBehavior alloc] initWithItems:#[circle1] mode:UIPushBehaviorModeInstantaneous];
[push setAngle:0 magnitude:1];
[self.animator addBehavior:push];
That yields:
By the way, it should be noted that the documentation outlines a few limitations to the path:
The path object you create must represent a convex polygon with counter-clockwise or clockwise winding, and the path must not intersect itself. The (0, 0) point of the path must be located at the center point of the corresponding dynamic item. If the center point does not match the path’s origin, collision behaviors may not work as expected.
But a simple circle path easily meets those criteria.
Or, for Swift users:
class CircleView: UIView {
var lineWidth: CGFloat = 3
var shapeLayer: CAShapeLayer = {
let _shapeLayer = CAShapeLayer()
_shapeLayer.strokeColor = UIColor.blue.cgColor
_shapeLayer.fillColor = UIColor.blue.withAlphaComponent(0.5).cgColor
return _shapeLayer
}()
override func layoutSubviews() {
super.layoutSubviews()
layer.addSublayer(shapeLayer)
shapeLayer.lineWidth = lineWidth
let center = CGPoint(x: bounds.midX, y: bounds.midY)
shapeLayer.path = circularPath(lineWidth: lineWidth, center: center).cgPath
}
private func circularPath(lineWidth: CGFloat = 0, center: CGPoint = .zero) -> UIBezierPath {
let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
return UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
}
override var collisionBoundsType: UIDynamicItemCollisionBoundsType { return .path }
override var collisionBoundingPath: UIBezierPath { return circularPath() }
}
class ViewController: UIViewController {
let animator = UIDynamicAnimator()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let circle1 = CircleView(frame: CGRect(x: 60, y: 100, width: 80, height: 80))
view.addSubview(circle1)
let circle2 = CircleView(frame: CGRect(x: 250, y: 150, width: 120, height: 120))
view.addSubview(circle2)
animator.addBehavior(UICollisionBehavior(items: [circle1, circle2]))
let behavior = UIDynamicItemBehavior(items: [circle1, circle2])
behavior.elasticity = 1
animator.addBehavior(behavior)
let push = UIPushBehavior(items: [circle1], mode: .instantaneous)
push.setAngle(0, magnitude: 1)
animator.addBehavior(push)
}
}
Ok, well first off.
The debug options you enabled show areas of transparent cells. The view that is the circle is actually a square with rounded edges.
All views are rectangular. The way they appear circular is by making the corners transparent (hence corner radius).
Second, what is it you're trying to do with UIKit Dynamics? What is on the screen looks like you're trying to create a game of some sort.
Dynamics is meant to be used for more natural and real looking animation of UI. It isn't meant to be a full-on physics engine.
If you want something like that then you're best using Sprite Kit.