iOS - HowTo: PanGestures mit SnapKit realisieren

29. Juni 2017

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:

Gesamtanimation

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".

SingleViewApp Template auswählen...

PanGestureDemo ... und speichern

Für die Installation des SnapKit Framework wird eine installiertes Carthage vorausgesetzt. Wer das noch nicht getan hat findet unter 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.

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

outlets

Außerdem setzen wir im 'Attributes Inspector' die Anzahl der 'Prototype Cells' auf '1':

prototypeCells

AnimationCell

Da alle Animationen in den entsprechenden Zellen ablaufen, erstellen wir uns als nächstes eine 'AnimationCell' die von 'UITableViewCell' erbt:

animationCell AnimationCell anlegen

Jetzt können wir im Interface Builder unsere 'Prototype Cell' auswählen

tableViewTree

und im 'Identity Inspector' die 'Class' in 'AnimationCell' ändern.

customClass

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.

identifier

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:

animation1

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

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

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

Gesamtanimation Gesamte Animation