SnapKit ist ein mächtiges Framework um dynamisch Constraints zu erzeugen und zu verändern. Das können wir uns auch bei der Animation von Gesten zu Nutze machen.
Das folgende HowTo wird das an einem kurzen Beispiel demonstrieren.
Grundlagen: Constraints
In XCode können View Layouts komfortabel mit dem Interface Builder in XCode erstellt werden. Meist wird man dort auf so genannte Constraints zurückgreifen, mit deren Hilfe Position und Größe von Objekten automatisch anhand den Abmessungen des Bildschirms berechnet wird. Im Interface Builder werden die Views grafisch angezeigt, dies ermöglicht die sofortige Kontrolle des Layouts.
Constraints müssen die Größe und Position aller Objekte einer Viewhierarchie festlegen.
Wenn Views dynamisch zur Laufzeit erstellt werden sollen, steht diese Methode natürlich nicht zur Verfügung. Nun hat man die Wahl den Frame (Position und Größe) des Views aufwendig selbst zu berechnen oder die Constraints programmatisch zu erstellen.
SnapKit
Das SnapKit Framework erleichtert die Syntax um Constraints zu erstellen, und unterstützt bereits iOS 8.
Constraints festlegen
SnapKit stellt eine Extension für UIView zur Verfügung über die einfach Constraints festgelegt werden können:
let innerView = UIView()
innerView.backgroundColor = .red
view.addSubview(innerView)
innerView.snp.makeConstraints { make in
make.top.equalTo(view.snp.top)
make.leading.equalTo(view.snp.leading)
make.trailing.equalTo(view.snp.trailing)
make.height.equalTo(view.snp.height)
}
Constraints ändern:
mit .updateContraints können vorher installierte Constraints geändert werden:
innerView.snp.updateConstraints { make in
make.height.equalTo(view.snp.height).offset(-50)
}
Weitere Informationen zu NSLayoutConstraint, NSLayoutAnchor und Visual Format Language gibt es auf den Apple Seiten
Die Animation:
In einem TableView soll jede Zelle einen View (rot) erhalten, der zum Beispiel zusätzliche Optionen zugänglich machen kann. Dieser wird vom linken Zellenrand nach rechts in die Zelle geschoben. Dadurch soll der Hauptinhalt (blauer View) nach rechts verdrängt werden. Wenn der rote View seine vollständige Größe erreicht hat, soll er nur noch verzögert nach rechts verschoben werden, während die linke Begrenzung des blauen Views weiterhin mit der Bewegungsgeschwindigkleit des Daumens animiert wird. Dadurch entsteht der graue Bereich der dem User verdeutlichen soll, dass der rote View die vollständige Größe erreicht hat. Nachdem die Geste durch den User beendet wurde, werden die Endpositionen der zwei Views angezeigt.
DemoProjekt
Wir beginnen mit einer "Single View Application" und nennen diese "PanGestureDemo".
*Template auswählen...*
*... und speichern* Für die Installation des SnapKit Framework wird eine installiertes Carthage vorausgesetzt. Wer das noch nicht getan hat findet unter [Carthage](https://github.com/Carthage/Carthage "Carthage") weitere Informationen und eine Installationsanleitung. Es lohnt sich :-)
Im Projektordner erstellen wir eine neue "Cartfile" mit der Zeile github "SnapKit/SnapKit ~> 3.1". Mit dem Konsolenbefehl carthage update --platform 'iOS' kann das Framework von Github geladen werden. Anschließend muss die Datei "SnapKit.framework" (liegt in Carthage/Build/iOS) per Drag and Drop zu "Embedded Binaries" in den Projekteinstellungen hinzugefügt werden. Weitere Informationen und alternative Möglichkeiten das Framework zu integrieren gibt es unter SnapKit.io.
*SnapKit unter EmbbededBinaries*TableView
Im Interface Builder hat uns XCode eine "View Controller" Szene vorbereitet. Mehr als diese Szene benötigen wir für unsere Demo nicht. Wir fügen der Szene einen 'TableView' hinzu und setzen Constraints so, dass der TableView im Vollbild angezeigt wird. Da sich die Größe des TableView zur Laufzeit nicht ändert (sondern nur die Views innerhalb der Zellen des TableViews), setzen wir sie im Interface Builder:
![tableViewconstraints]
*TableView Constraints* Im 'Connections Inspector' müssen die 'dataSource' und der 'delegate' mit dem ViewController verbunden werden:
Außerdem setzen wir im 'Attributes Inspector' die Anzahl der 'Prototype Cells' auf '1':
AnimationCell
Da alle Animationen in den entsprechenden Zellen ablaufen, erstellen wir uns als nächstes eine 'AnimationCell' die von 'UITableViewCell' erbt:
*AnimationCell anlegen* Jetzt können wir im Interface Builder unsere 'Prototype Cell' auswählen
und im 'Identity Inspector' die 'Class' in 'AnimationCell' ändern.
AnimationCell als CustomClass registrieren
Im 'Attributes Inspector' muss noch der 'Identifier' in 'animationCell' geändert werden, dann sind wir mit der Konfiguration im Interface Builder fertig.
Data Source und Delegate
In unserer 'ViewController' Klasse ergänzen wir als nächstes die Funktionen für die 'DataSource' und den 'Delegate':
import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
override func viewDidLoad() {
super.viewDidLoad()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
//Mark: - Data Source
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 6;
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "animationCell", for: indexPath)
return cell
}
//Mark: - Delegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
}
}
Konfiguration der Zelle
Das Layout unserer Zellen entwerfen wir programmatisch in unserer 'AnimationCell' Klasse:
Dafür benötigen wir zunächste zwei Views: Wir nennen diese 'leftView' und 'rightView'.
class AnimationCell: UITableViewCell {
let leftView = UIView()
let rightView = UIView()
...
}
Mit SnapKit können wir diese jetzt in der Zelle platzieren (import SnapKit nicht vergessen :-) ).
In awakeFromNib() fügen wir folgende Zeilen ein
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
contentView.addSubview(leftView)
contentView.addSubview(rightView)
leftView.snp.makeConstraints { make in
make.top.equalToSuperview()
make.bottom.equalToSuperview()
make.leading.equalToSuperview()
make.width.equalTo(10)
}
rightView.snp.makeConstraints { make in
make.top.equalToSuperview()
make.bottom.equalToSuperview()
make.leading.equalToSuperview().offset(10)
make.trailing.equalToSuperview()
}
leftView.backgroundColor = .red
rightView.backgroundColor = .blue
backgroundColor = .gray
...
}
Mit make.width.equalTo(10) und make.leading.equalToSuperView().offset(10) stellen wir sicher, dass unser 'leftView' zehn Punkte breit ist und der 'rightView' direkt daran anschließt.
Statt make.leading.equalToSuperView().offset(10) könnten wir auch make.leading.equalTo(leftView.snp.trailing) nehmen. Obige Variante bringt uns allerdings später Vorteile bei der Berechnung der Lücke zwischen dem leftView und rightView, wenn die Geste über die maximale Größe des leftView hinaus geht.
Für die Gestenerkennung brauchen wir außerdem noch einen 'UIPanGestureRecognizer':
override func awakeFromNib() {
...
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(animatePanGesture(recognizer:)))
panGestureRecognizer.delegate = self
contentView.addGestureRecognizer(panGestureRecognizer)
}
'animatePanGesture(recognizer:)' wertet den Status des UIPanGestureRecognizer aus (.began, .changed, .ended). Dazu schreiben wir eine neue Funktion 'continueSwipe(recognizer:)' die aufgerufen wird wenn sich die Geste geändert (fortgesetzt) hat:
func animatePanGesture(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
break
case .changed:
continueSwipe(recognizer: recognizer)
break
case .ended:
break
default:
break
}
}
In 'continueSwipe(recognizer:)' werden wir als erstes die Breite des 'leftView' anhand der (horizontal) überstrichenen Strecke der Geste (Translation) in der Zelle berechnen. Die zurückgelegte Strecke liefert uns recognizer.translation(in: self).x. Um die Translation in der Zelle zu erhalten müssen wir 'translation(in:)' den entsprechenden View übergeben. Das ist in diesem Fall die Zelle, also self. Mit .x erhalten wir die horizontal überstrichene Strecke.
Die Berechung lagern wir in getViewOffset(recognizer: UIPanGestureRecognier) -> CGFloat aus:
func getViewOffset(recognizer: UIPanGestureRecognizer) -> CGFloat {
var viewOffset: CGFloat = 0
viewOffset = recognizer.translation(in: self).x + 10
if viewOffset <= 10 {
viewOffset = 10
}
return viewOffset
}
Dadurch, dass die Funktion immer mindestens 10 zurück gibt, kann der 'leftView' nicht nach links vom 'rightView' überdeckt werden. (Negative Werte aus recognizer.tranlation(recognizer:) entsprechen Gesten nach links).
'continueSwipe(recognizer:)' sieht damit wie folgt aus:
func continueSwipe(recognizer: UIPanGestureRecognizer) {
let viewOffset = getViewOffset(recognizer: recognizer)
leftView.snp.updateConstraints { make in
make.width.equalTo(viewOffset)
}
let rightViewOffset = getRightViewOffset(recognizer: recognizer)
rightView.snp.updateConstraints { make in
make.leading.equalToSuperview().offset(viewOffset)
make.trailing.equalToSuperview().offset(viewOffset - 10) //(1)
}
}
Mit (1) stellen wir sicher, dass der 'rightView' nicht einfach nur gestaucht wird, sondern sein Ende nach rechts aus der Zelle raus geschoben wird.
Damit erhalten wir schon unsere erste Animation:
Wie man sieht, ist die Animation zwar nach links begrenzt, allerdings nicht nach rechts. Wir führen für die Berenzung nach links und rechts die folgenden Konstanten ein:
(Wir nennen 'leftView' geöffnet, wenn er 50 Punkte breit ist bzw. geschlossen, wenn die Breite 10 Punkte beträgt)
class AnimationCell: UITableViewCell {
...
let widthOfOpenedLeftView: CGFloat = 50
let widthOfClosedLeftView: CGFloat = 10
...
}
Außerdem trennen wir die Berechnung des 'viewOffset' auf in einen Offset für den linken bzw. rechten View:
func getLeftViewOffset(recognizer: UIPanGestureRecognizer) -> CGFloat {
var leftViewOffset: CGFloat = 0
leftViewOffset = recognizer.translation(in: self).x + widthOfClosedView
if leftViewOffset <= widthOfClosedLeftView {
leftViewOffset = widthOfClosedLeftView
}
if leftViewOffset >= widthOfOpenedLeftView {
leftViewOffset = widthOfOpenedLeftView
}
return leftViewOffset
}
func getRightViewOffset(recognizer: UIPanGestureRecognizer) -> CGFloat {
var rightViewOffset: CGFloat = 0
rightViewOffset = recognizer.translation(in: self).x + widthOfClosedView
if rightViewOffset <= widthOfClosedLeftView {
rightViewOffset = widthOfClosedLeftView
}
return rightViewOffset
}
Damit können wir continueSwipe(recognizer:) umschreiben:
func continueSwipe(recognizer: UIPanGestureRecognizer) {
let leftViewOffset = getLeftViewOffset(recognizer: recognizer)
leftView.snp.updateConstraints { make in
make.width.equalTo(leftViewOffset)
}
let rightViewOffset = getRightViewOffset(recognizer: recognizer)
rightView.snp.updateConstraints { make in
make.leading.equalToSuperview().offset(rightViewOffset)
make.trailing.equalToSuperview().offset(rightViewOffset - widthOfClosedLeftView)
}
}
*Animation mit Zwischenraum* Um einen 'Zieh-'Effekt am linken View zu erhalten addieren wir zur maximalen Breite des Views ein fünftel des Abstandes zwischen der maximalen Breite und dem Beginn des rechten Views:
func getLeftViewOffset(recognizer: UIPanGestureRecognizer) -> CGFloat {
var leftViewOffset: CGFloat = 0
leftViewOffset = recognizer.translation(in: self).x + widthOfClosedLeftView
if leftViewOffset <= widthOfClosedLeftView {
leftViewOffset = widthOfClosedLeftView
}
if leftViewOffset >= widthOfOpenedLeftView {
leftViewOffset = widthOfOpenedLeftView + (leftViewOffset - widthOfOpenedLeftView) / 5
}
return leftViewOffset
}
*Linker View wird nach rechts nachgezogen* Aktuell bleiben die Views in der Position stehen, in der die Geste beendet wird. Stattdessen wäre es besser die Zelle zu öffnen oder zu schließen, je nachdem in welche die Richtung sich die Geste am Ende bewegt.
Wenn die Geschwindigkeit der Geste negativ ist wird die Zelle geschlossen andernfalls geöffnet:
func endSwipe(recognizer: UIPanGestureRecognizer) {
if recognizer.velocity(in: self).x < 0 {
closeCell()
} else {
openCell()
}
}
func openCell() {
leftView.snp.updateConstraints { make in
make.width.equalTo(widthOfOpenedLeftView)
}
rightView.snp.updateConstraints { make in
make.leading.equalToSuperview().offset(widthOfOpenedLeftView)
make.trailing.equalToSuperview().offset(widthOfOpenedLeftView - widthOfClosedLeftView)
}
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
self.contentView.layoutIfNeeded()
})
currentCellStatus = .opened
}
func closeCell() {
leftView.snp.updateConstraints { make in
make.width.equalTo(widthOfClosedLeftView)
}
rightView.snp.updateConstraints { make in
make.leading.equalToSuperview().offset(widthOfClosedLeftView)
make.trailing.equalToSuperview().offset(0)
}
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
self.contentView.layoutIfNeeded()
})
currentCellStatus = .closed
}
Außerdem führen wir noch eine Enumeration ein, die angibt ob die Zelle geschlossen oder geöffnet ist:
enum CellStatus {
case opened
case closed
}
class AnimationCell: UITableViewCell {
var currentCellStatus: CellStatus = .closed
...
In 'animatePanGesture(recognizer:)' rufen wir 'endSwipe(recognizer:)' auf, wenn die Geste beendet wurde:
func animatePanGesture(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
break
case .changed:
continueSwipe(recognizer: recognizer)
break
case .ended:
endSwipe(recognizer: recognizer)
break
default:
break
}
}
Unsere bisherigen Animationen beginnen immer mit geschlossener Zelle. Mit der neu eingeführten Variable können wir die 'viewOffsets' nun auch bei initial geöffneter Zelle korrekt berechnen.(In diesem Fall müssen wir 'widthOfOpenedLeftView' als Grundlagen für die Breite des leftView bzw. die Position des rightView verwenden):
func getLeftViewOffset(recognizer: UIPanGestureRecognizer) -> CGFloat {
var leftViewOffset: CGFloat = 0
switch currentCellStatus {
case .opened:
leftViewOffset = widthOfOpenedLeftView + recognizer.translation(in: self).x
break
case .closed:
leftViewOffset = widthOfClosedLeftView + recognizer.translation(in: self).x
break
default:
break
}
if leftViewOffset <= widthOfClosedLeftView {
leftViewOffset = widthOfClosedLeftView
}
if leftViewOffset >= widthOfOpenedLeftView {
leftViewOffset = widthOfOpenedLeftView + (leftViewOffset - widthOfOpenedLeftView) / 5
}
return leftViewOffset
}
func getRightViewOffset(recognizer: UIPanGestureRecognizer) -> CGFloat {
var rightViewOffset: CGFloat = 0
switch currentCellStatus {
case .opened:
rightViewOffset = widthOfOpenedLeftView + recognizer.translation(in: self).x
break
case .closed:
rightViewOffset = widthOfClosedLeftView + recognizer.translation(in: self).x
break
default:
break
}
if rightViewOffset <= widthOfClosedLeftView {
rightViewOffset = widthOfClosedLeftView
}
return rightViewOffset
}
*Gesamte Animation*