Podcast
Videos
September 5, 2022
Nov 2022
7 Min

iOS - HowTo: PanGestures mit SnapKit realisieren

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*

Andreas Link
Andreas Link
Anh Dung Pham
Anh Dung Pham
Cihat Gündüz
Cihat Gündüz
Andreas Link
Ekrem Sentürk
Eva Maria Stock
Eva-Marie Stock
Andreas Link
Giulia Maier
Inken Marei Kolthoff
Inken Marei Kolthoff
Janina Baumann
Janina Baumann
Janina Bokeloh
Janina Bokeloh
Jeanette Schmidt
Jeanette Schmidt
Jens Krug
Jens Krug
Kajorn Pathomkeerati
Kajorn Pathomkeerati
Karl Barth
Karl Barth
Kay Dollt
Kay Dollt
Murat Yilmaz
Murat Yilmaz
Thorsten Hack
Thorsten Hack
Thorsten Hack
Thorsten Hack
Inken Marei Kolthoff
Cynthia Murat
Inhaltsverzeichnis

Weitere Artikel

Android App programmieren
Kay Dollt
26.11.2022
7 Min

Android App programmieren

Native Apps für das beliebte Betriebssystem Android zählen zu unseren Kernkompetenzen.

Artikel lesen
Website vs. App - 10 Gründe warum eine App besser ist
Kay Dollt
26.11.2022
8 Min

Website vs. App - 10 Gründe warum eine App besser ist

Wenn du deinem Unternehmen oder deiner Institution zu mehr digitaler Präsenz verhelfen möchtest, stellt sich sehr schnell die Frage, ob du lieber auf eine mobile Website oder eine App setzt.

Artikel lesen
Bitrise CI für iOS optimal nutzen - projektübergreifende Konfigurationen
Cihat Gündüz
26.11.2022
12 Min

Bitrise CI für iOS optimal nutzen - projektübergreifende Konfigurationen

Mit der Zeit stecken wir viel Arbeit in die CI-Setups unserer Projekte.

Artikel lesen

Jetzt kostenloses Strategiegespräch sichern!

Die Beratungen sind grundsätzlich schnell ausgebucht, deshalb fülle jetzt in 2 Minuten das kurze Formular aus.

Jetzt Strategiegespräch sichern