Skip to content

Latest commit

 

History

History
176 lines (143 loc) · 6.87 KB

InteractiveAnimations.md

File metadata and controls

176 lines (143 loc) · 6.87 KB

Interactive Animations

TableView

There is a lot going on in this demo. Read Nathan's blog post for a detailed break down. But basically we are creating a view, animating it's display up using UIViewPropertyAnimator and then taking into account a whole bunch of things like

  • direction
  • reveribility
  • fraction complete
  • custom tap gesture to act more like a scroll
//
//  ViewController.swift
//  InteractiveAnimations
//
//  Created by Nathan Gitter on 9/4/17.
//  Copyright © 2017 Nathan Gitter. All rights reserved.
//

import UIKit
import UIKit.UIGestureRecognizerSubclass

private enum State {
    case closed
    case open
}

extension State {
    var opposite: State {
        switch self {
        case .open: return .closed
        case .closed: return .open
        }
    }
}

class ViewController: UIViewController {

    private let popupOffset: CGFloat = 440

    private lazy var popupView: UIView = {
        let view = UIView()
        view.backgroundColor = .gray
        view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner] // animate corner radius
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        layout()
        popupView.addGestureRecognizer(panRecognizer)
    }

    private var bottomConstraint = NSLayoutConstraint()

    private func layout() {
        popupView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(popupView)
        popupView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        popupView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        bottomConstraint = popupView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: popupOffset)
        bottomConstraint.isActive = true
        popupView.heightAnchor.constraint(equalToConstant: 500).isActive = true
    }

    private var currentState: State = .closed

    private var transitionAnimator = UIViewPropertyAnimator()

    private var animationProgress: CGFloat = 0

    private lazy var panRecognizer: InstantPanGestureRecognizer = {
        let recognizer = InstantPanGestureRecognizer()
        recognizer.addTarget(self, action: #selector(popupViewPanned(recognizer:)))
        return recognizer
    }()

    private func animateTransitionIfNeeded(to state: State, duration: TimeInterval) {
        if transitionAnimator.isRunning { return }

        // animate the transition
        transitionAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1, animations: {
            switch state {
            case .open:
                self.bottomConstraint.constant = 0
                self.popupView.layer.cornerRadius = 20
            case .closed:
                self.bottomConstraint.constant = self.popupOffset
                self.popupView.layer.cornerRadius = 0
            }
            self.view.layoutIfNeeded()
        })

        // record the current state once complete
        transitionAnimator.addCompletion { position in
            switch position {
            case .start:
                self.currentState = state.opposite
            case .end:
                self.currentState = state
            case .current:
                ()
            @unknown default:
                ()
            }

            // reset to current state in case offset was adjusted
            switch self.currentState {
            case .open:
                self.bottomConstraint.constant = 0
            case .closed:
                self.bottomConstraint.constant = self.popupOffset
            }
        }
        transitionAnimator.startAnimation()
    }

    // scrub the animation taking into account how much complete and whether to reverse based on velocity and direction
    @objc private func popupViewPanned(recognizer: UIPanGestureRecognizer) {
        switch recognizer.state {
        case .began:
            animateTransitionIfNeeded(to: currentState.opposite, duration: 1.5)
            transitionAnimator.pauseAnimation()
            animationProgress = transitionAnimator.fractionComplete
        case .changed:
            let translation = recognizer.translation(in: popupView)
            var fraction = -translation.y / popupOffset
            if currentState == .open { fraction *= -1 }
            if transitionAnimator.isReversed { fraction *= -1 }
            transitionAnimator.fractionComplete = fraction + animationProgress
        case .ended:
            let yVelocity = recognizer.velocity(in: popupView).y
            let shouldClose = yVelocity > 0
            if yVelocity == 0 {
                transitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
                break
            }
            switch currentState {
            case .open:
                if !shouldClose && !transitionAnimator.isReversed { transitionAnimator.isReversed = !transitionAnimator.isReversed }
                if shouldClose && transitionAnimator.isReversed { transitionAnimator.isReversed = !transitionAnimator.isReversed }
            case .closed:
                if shouldClose && !transitionAnimator.isReversed { transitionAnimator.isReversed = !transitionAnimator.isReversed }
                if !shouldClose && transitionAnimator.isReversed { transitionAnimator.isReversed = !transitionAnimator.isReversed }
            }
            transitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
        default:
            ()
        }
    }

}

// The interruption behavior works, but is awkward. In order for the pan to be recognized, the user must tap on the screen and then move their finger in any direction. We would prefer the behavior to act like a scroll view, which allows the user to “catch” the view with only a touch down. Currently, the tap gesture and pan gesture are only fired on touch up and touches moved, respectively. In order to fire an event on touch down, we can create our own custom gesture recognizer.

// This pan gesture subclass enters the began state on touch down. It allows us to replace both of our previous gesture recognizers. The “tap” is now an “instant pan” that ends right after it begins. By using this custom gesture recognizer, we can improve the behavior of our previous tap/pan solution as well as simplify our logic.

class InstantPanGestureRecognizer: UIPanGestureRecognizer {

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        if (self.state == UIGestureRecognizer.State.began) { return }
        super.touchesBegan(touches, with: event)
        self.state = UIGestureRecognizer.State.began
    }

}

Links that help