Giochiamo con UIDynamics in iOS 9
Cosa è cambiato in UIDynamics con iOS 9 — nuovi collision bound, field behavior, pin attachment — e come metterli insieme per costruire una mini clone di Ball King.
Versione italiana dell’articolo originale del 19 giugno 2015; traduzione a cura del team.
UIDynamics è stata un’aggiunta benvenuta all’SDK iOS 7. In pratica è un physics engine che sta dietro le comuni UIView, permettendoci di assegnare proprietà fisiche agli elementi della UI. L’API è abbastanza lineare, quindi puoi creare facilmente animazioni e transizioni che hanno un ottimo feel. Avevo già coperto le basi in questo articolo un po’ di tempo fa; stavolta vediamo le novità di UIDynamics in iOS 9.
Collision Bounds
La prima release di UIDynamics arrivava con un sistema di collisioni (fornito da UICollisionBehavior) che supportava solo corpi rettangolari. Aveva senso, dato che le UIView sono basate su frame rettangolari, ma non è raro avere una view circolare, o ancora meglio un nostro Bezier path custom. Con iOS 9 è stata aggiunta una nuova property al protocollo UIDynamicItem: UIDynamicItemCollisionBoundsType, che accetta uno di questi valori dell’enum:
RectangleEllipsePath
La property è readonly, quindi per cambiarla bisogna fornire una sottoclasse:
class Ellipse: UIView {
override var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return .Ellipse
}
}
Questa è una UIView con il collision bound di default:

Questa è la stessa UIView con .Ellipse:

Questo copre le view tonde; se vogliamo strafare e disegnare una view più complessa con un rigid body coerente possiamo usare l’enum .Path e fare anche override di:
var collisionBoundingPath: UIBezierPath { get }
Il path può essere qualunque cosa tu voglia, purché sia convesso (cioè: per ogni coppia di punti dentro il poligono, il segmento che li unisce è sempre interamente contenuto nel poligono stesso) e con winding in senso antiorario.
Il vincolo della convessità potrebbe essere significativo, quindi è stato introdotto UIDynamicItemGroup per specificare forme come unione di forme diverse. In questo modo, finché ogni forma del gruppo è convessa, va tutto bene anche se la composizione risultante è concava.
Field Behavior
I field behavior sono un nuovo tipo di behavior applicato all’intera scena. L’esempio più comune che abbiamo usato implicitamente fin dall’inizio è UIGravityBehavior, che applica una forza verso il basso a ogni item della scena. Ora possiamo usare un nuovo set di field force, come Radial (le forze sono più intense al centro e più deboli ai bordi), Noise (forze di magnitudo diversa sparse nel field), e così via.
Dynamic Item Behavior
UIDynamicItemBehavior ha ricevuto un paio di nuove property interessanti:
var charge: CGFloat
var anchored: Bool
charge rappresenta la carica elettrica che può influenzare come un item si muove in un campo elettrico o magnetico (sì, è folle), mentre anchored trasforma in sostanza una shape in un oggetto statico che partecipa alle collisioni senza però rispondere (se qualcosa lo colpisce, lui non si muove): perfetto per rappresentare un pavimento o un muro.
Attachment Behavior
UIAttachmentBehavior è stato rinnovato e ora ha un esercito di nuovi metodi e property, come frictionTorque e attachmentRange. Gli attachment ora sono più flessibili: possiamo specificare movimenti relativi di scorrimento, attachment fissi, attachment “a corda” e — il mio preferito — pin attachment. Pensa a due oggetti inchiodati insieme: quello è il pin attachment.
Questo più o meno copre le novità di UIDynamics, ora è il momento di mettere via il changelog e iniziare a costruire qualcosa di stupido.
Diamoci alla palla
Ho passato un sacco di tempo morto con Ball King nell’ultima settimana. È un piccolo time waster geniale: il concetto è semplice ma eseguito bene. Inoltre adotta lo stesso modello di monetizzazione di Crossy Road (Apple design winner): non infastidisce il giocatore in alcun modo. Bravi.

Una cosa che mi piace molto è il modello fisico della palla e come il tabellone del canestro reagisce quando viene colpito. Sembra un esercizio divertente per testare le novità di UIDynamics elencate sopra. Vediamo passo per passo come costruire la nostra versione raffazzonata: BallSwift.
Il canestro
Il canestro lo costruiamo con una singola UIView come tabellone, un paio di view con rigid body come braccia sinistra e destra del ferro, e una view in primo piano come ferro vero e proprio (senza physic body). Usando la classe Ellipse definita prima possiamo creare la rappresentazione visiva della nostra scena di gioco:
/*
Build the hoop, setup the world appearance
*/
func buildViews() {
board = UIView(frame: CGRect(x: hoopPosition.x, y: hoopPosition.y, width: 100, height: 100))
board.backgroundColor = .whiteColor()
board.layer.borderColor = UIColor(red: 0.98, green: 0.98, blue: 0.98, alpha: 1).CGColor
board.layer.borderWidth = 2
board.addSubview({
let v = UIView(frame: CGRect(x: 30, y: 43, width: 40, height: 40))
v.backgroundColor = .clearColor()
v.layer.borderColor = UIColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1).CGColor
v.layer.borderWidth = 5
return v
}())
leftHoop = Ellipse(frame: CGRect(x: hoopPosition.x + 20, y: hoopPosition.y + 80, width: 10, height: 6))
leftHoop.backgroundColor = .clearColor()
leftHoop.layer.cornerRadius = 3
rightHoop = Ellipse(frame: CGRect(x: hoopPosition.x + 70, y: hoopPosition.y + 80, width: 10, height: 6))
rightHoop.backgroundColor = .clearColor()
rightHoop.layer.cornerRadius = 3
hoop = UIView(frame: CGRect(x: hoopPosition.x + 20, y: hoopPosition.y + 80, width: 60, height: 6))
hoop.backgroundColor = UIColor(red: 177.0/255.0, green: 25.0/255.0, blue: 25.0/255.0, alpha: 1)
hoop.layer.cornerRadius = 3
[board, leftHoop, rightHoop, floor, ball, hoop].map({self.view.addSubview($0)})
}
Niente di nuovo qui: il canestro è creato programmaticamente e posizionato nella costante CGPoint hoopPosition. L’ordine delle view è importante però: vogliamo che il ferro stia sopra al pallone.
Bulloni e dadi
La parte più importante del canestro sono le braccia sinistra e destra. Hanno bisogno di un corpo fisico tondo (così la collisione con la palla è fluida) e devono essere imbullonate al tabellone e al ferro frontale. Quei due saranno UIDynamicItem basici e non parteciperanno direttamente alle collisioni. Il neonato pin attachment è perfetto per questo lavoro: tiene tutto insieme niente male, come si vede in questo disegno orripilante:

Il pin può essere attaccato solo a una coppia di view alla volta, in un determinato punto spaziale assoluto:
let bolts = [
CGPoint(x: hoopPosition.x + 25, y: hoopPosition.y + 85), // leftHoop -> Board
CGPoint(x: hoopPosition.x + 75, y: hoopPosition.y + 85), // rightHoop -> Board
CGPoint(x: hoopPosition.x + 25, y: hoopPosition.y + 85), // hoop -> Board (L)
CGPoint(x: hoopPosition.x + 75, y: hoopPosition.y + 85)] // hoop -> Board (R)
// Build the board
zip([leftHoop, rightHoop, hoop, hoop], offsets).map({
(item, offset) in
animator?.addBehavior(UIAttachmentBehavior.pinAttachmentWithItem(item, attachedToItem: board, attachmentAnchor: bolts))
})
Se non partecipi alla corsa al functional Swift potresti non avere familiarità con zip e map. A prima vista può sembrare contorto, ma è piuttosto semplice: ogni view è accoppiata con l’offset point in cui faremo il pin dell’attachment, generando un array di tuple che viene poi usato nella funzione map che, come suggerisce il nome, fa un mapping tra ogni elemento dell’array e la closure fornita. Risultato: entrambi i bracci del ferro vengono imbullonati al tabellone e al ferro frontale così:
- Braccio sinistro imbullonato sulla sinistra del tabellone
- Braccio destro imbullonato sulla destra del tabellone
- Ferro imbullonato sulla sinistra del tabellone
- Ferro imbullonato sulla sinistra del tabellone
Lo step successivo è appendere il tabellone, lasciandolo libero di oscillare, così una collisione lo fa dondolare un po’ come succede in Ball King:
// Set the density of the hoop, and fix its angle
// Hang the hoop
animator?.addBehavior({
let attachment = UIAttachmentBehavior(item: board, attachedToAnchor: CGPoint(x: hoopPosition.x, y: hoopPosition.y))
attachment.length = 2
attachment.damping = 5
return attachment
}())
animator?.addBehavior({
let behavior = UIDynamicItemBehavior(items: [leftHoop, rightHoop])
behavior.density = 10
behavior.allowsRotation = false
return behavior
}())
// Block the board rotation
animator?.addBehavior({
let behavior = UIDynamicItemBehavior(items: [board])
behavior.allowsRotation = false
return behavior
}())
Il canestro è pronto. Occupiamoci della palla, partendo da una sottoclasse custom di UIImageView con un physic body tondo, esattamente come la classe Ellipse:
class Ball: UIImageView {
override var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return .Ellipse
}
}
Poi possiamo istanziare la palla come una normale UIImageView:
let ball: Ball = {
let ball = Ball(frame: CGRect(x: 0, y: 0, width: 28, height: 28))
ball.image = UIImage(named: "ball")
return ball
}()
Infine ne settiamo le proprietà fisiche:
// Set the elasticity and density of the ball
animator?.addBehavior({
let behavior = UIDynamicItemBehavior(items: [ball])
behavior.elasticity = 1
behavior.density = 3
behavior.action = {
if !CGRectIntersectsRect(self.ball.frame, self.view.frame) {
self.setupBehaviors()
self.ball.center = CGPoint(x: 40, y: self.view.frame.size.height - 100)
}
}
return behavior
}())
In questo pezzo setto l’elasticity (quanto deve rimbalzare dopo una collisione), la density (pensala come il peso) e una comoda action closure che resetta lo stato del mondo quando la palla esce dall’area di gioco (la main view).
Collisioni e gravità
Ho già menzionato la nuova property anchored di UIDynamicItemBehavior, che disabilita il behavior dinamico di un oggetto mantenendolo però nel loop delle collisioni. Sembra un ottimo modo per costruire un pavimento solido:
// Anchor the floor
animator?.addBehavior({
let behavior = UIDynamicItemBehavior(items: [floor])
behavior.anchored = true
return behavior
}())
Se ti dimentichi di settare questa property ti gratterai la testa parecchio. Io l’ho fatto. Ok, tutto è pronto, manca solo un po’ di gravità e un set di collisioni:
animator?.addBehavior(UICollisionBehavior(items: [leftHoop, rightHoop, floor, ball]))
animator?.addBehavior(UIGravityBehavior(items: [ball]))
La gravità è un field behavior che di default applica una forza verso il basso di 1 point al secondo. Il collision behavior prende come parametro solo le view che devono collidere tra loro. Il mondo è pronto, ora possiamo applicare una forza istantanea alla palla e incrociare le dita:
let push = UIPushBehavior(items: [ball], mode: .Instantaneous)
push.angle = -1.35
push.magnitude = 1.56
animator?.addBehavior(push)

E voilà, è davvero grezzo, ma è stato un sacco divertente da costruire (sì, le nuvole e i cespugli sono lo stesso disegno, come in Super Mario). Come sempre trovi il source sulla nostra pagina GitHub.