iosuikitcore-animationuikit-dynamics

UIKit Dynamics: recognize rounded Shapes and Boundaries


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: small circle is actually a square

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?


Solution

  • 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:

    enter image description here

    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)
        }
    
    }