iOS 9 Swift Programming Cookbook (2015)
Chapter 13. UI Dynamics
13.7 Handling Non-Rectangular Views
Problem
You want to create non-rectangular shaped views in your app, and want your collision detection to work properly with these views.
Solution
Follow these steps:
1. Subclass UIView and override the collisionBoundsType variable of type UIDynamicItemCollisionBoundsType. In there, return UIDynamicItemCollisionBoundsType.Path. This makes sure that you have your own Bezier path of type UIBezierPath, and you want that to define the edges of your view, which are essentially the edges that your collision detector has to detect.
2. Override the collisionBoundingPath variable of type UIBezierPath in your view and in there, return the path that defines your view’s edges.
3. In your UIBezierPath, create the shape you want for your view. The first point in this shape has to be the center of your shape. You have to draw your shape in a convex and counterclockwise manner.
4. Override the drawRect(_:) method of your view and draw your path there.
5. Add your behaviors to your new and awesome view and then create an animator of type UIDynamicAnimator (see Recipe 13.1).
6. Optionally, throw in a noise field as well to create some random movements between your dynamic items (see Recipe 13.4).
NOTE
I am going to draw a pentagon view in this recipe. I won’t teach how that is drawn because you can find the basic rules of drawing a pentagon online and that is entirely outside the scope of this book.
Discussion
Here, we are aiming to create a dynamic field that looks like Figure 13-8. The views I have created are a rectangle and a pentagon. We will have proper collision detection between the two views.
Figure 13-8. Rectange and pentagon with collision detection
Let’s start off by creating a little extension on the StrideThrough structure. You’ll see soon, when we code our pentagon view, that I am going to go through 5 points of the pentagon that are drawn on the circumference of the bounding circle, plot them on the path, and draw lines between them. I will use stride(from:through:by:) to create the loop. I would like to performa a function over every item in this array of numbers, hence the following extension:
extension StrideThrough{
func forEach(f: (Generator.Element) -> Void){
for item in self{
f(item)
}
}
}
Let’s move on to creating a class named PentagonView that subclasses UIView. I want this view to be constructed only by a diameter. This will be the diameter of the bounding circle within which the pentagon will reside. Therefore, we need a diameter variable, along with our constructor and perhaps a nice class method constructor for good measure:
class PentagonView : UIView{
private var diameter: CGFloat = 0.0
class func pentagonViewWithDiameter(diameter: CGFloat) -> PentagonView{
return PentagonView(diameter: diameter)
}
init(diameter: CGFloat){
self.diameter = diameter
super.init(frame: CGRectMake(0, 0, diameter, diameter))
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
var radius: CGFloat{
return diameter / 2.0
}
...
We need next to create our UIBezierPath. There are 5 slices inside a pentagon and the angle between each slice, from the center of the pentagon, is 360/5 or 72 degrees. Using this knowledge, we need to be able to, given the center of our pentagon, plot the 5 points onto the circumference of the bounding circle.
func pointFromAngle(angle: Double) -> CGPoint{
let x = radius + (radius * cos(CGFloat(angle)))
let y = radius + (radius * sin(CGFloat(angle)))
return CGPoint(x: x, y: y)
}
lazy var path: UIBezierPath = {
let path = UIBezierPath()
path.moveToPoint(self.pointFromAngle(0))
let oneSlice = (M_PI * 2.0) / 5.0
let lessOneSlice = (M_PI * 2.0) - oneSlice
stride(from: oneSlice, through: lessOneSlice, by: oneSlice).forEach {
path.addLineToPoint(self.pointFromAngle($0))
}
path.closePath()
return path
}()
That was the most important part of this recipe, if you are curious. Once we have the path, we can draw our view using it:
override func drawRect(rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else{
return
}
UIColor.clearColor().setFill()
CGContextFillRect(context, rect)
UIColor.yellowColor().setFill()
path.fill()
}
The next and last step in creating our pentagon view is to override the collisionBoundsType and the collisionBoundingPath variable.
override var collisionBoundsType: UIDynamicItemCollisionBoundsType{
return UIDynamicItemCollisionBoundsType.Path
}
override var collisionBoundingPath: UIBezierPath{
let path = self.path.copy() as! UIBezierPath
path.applyTransform(CGAffineTransformMakeTranslation(-radius, -radius))
return path
}
NOTE
I am applying a translation transform on our Bezier path before giving it to the collision detector. The reason behind this is that the first point of our path is in the center of our shape, so we need to subtract the x and y position of the center from the path to translate our path to its actual value for the collision detector to use. Otherwise, the path will be outside the actual pentagon shape. Because the x and y position of the center of our pentagon are in fact the radius of the pentagon and the radius is half the diameter, we provide the radius here to the translation.
Now let’s extend UIView so that we can add a pan gesture recognizer to it with one line of code. Both the square and our pentagon view will easily get a pan gesture recognizer:
extension UIView{
func createPanGestureRecognizerOn(obj: AnyObject){
let pgr = UIPanGestureRecognizer(target: obj, action: "panning:")
addGestureRecognizer(pgr)
}
}
Let’s move on to the view controller. Add the following components to your view controller, just as we did in Recipe 13.4:
§ An animator of type UIDynamicAnimator
§ A collision detector of type UICollisionBehavior
§ A noise field of type UIFieldBehavior
Let’s bundle the collision detector and the noise field into an array. This lets us add them to our animator faster with the extensions that we created in Recipe 13.5.
var behaviors: [UIDynamicBehavior]{
return [self.collision, self.noise]
}
The next step is to create our square view. This one is easy. It is just a simple view with a pan gesture recognizer:
lazy var squareView: UIView = {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
view.createPanGestureRecognizerOn(self)
view.backgroundColor = UIColor.brownColor()
return view
}()
The juicy part, now! The pentagon view. Create it with the constructor of PentagonView and then place it in the center of our view:
lazy var pentagonView: PentagonView = {
let view = PentagonView.pentagonViewWithDiameter(100)
view.createPanGestureRecognizerOn(self)
view.backgroundColor = UIColor.clearColor()
view.center = self.view.center
return view
}()
Group your views up and add them to your reference view:
var views: [UIView]{
return [self.squareView, self.pentagonView]
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(squareView)
view.addSubview(pentagonView)
animator.addBehaviors(behaviors)
}
Last but not least, handle panning. As soon as the user starts to pan one of our views around, pause all the behaviors. Once the panning is finished, re- enable the behaviors:
@IBAction func panning(sender: UIPanGestureRecognizer) {
switch sender.state{
case .Began:
collision.removeItems()
noise.removeItems()
case .Changed:
sender.view?.center = sender.locationInView(view)
case .Ended, .Cancelled:
collision.addItems(views)
noise.addItems(views)
default: ()
}
}
Wrapping up, I want to clarify a few things. We extended UIDynamicAnimator and added the addBehaviors(_:) method to it in Recipe 13.5. In the same recipe, we added the addItems(_:) method to UIFieldBehavior. But in our current recipe, we also need removeItems(), so I think it’s best to show that extension again with the new code:
extension UIFieldBehavior{
public func addItems(items: [UIDynamicItem]){
for item in items{
addItem(item)
}
}
public func removeItems(){
for item in items{
removeItem(item)
}
}
}
Please extend UICollisionBehavior in the exact same way and add the addItems(_:) and removeItems() methods to that class as well.
See Also