Unverified Commit ffa8d7c2 authored by Zach Knox's avatar Zach Knox
Browse files

Cards! Using DeckTransition

Also lots of other things
parent dddbff61
......@@ -10,6 +10,7 @@ target 'WhatsOpen' do
# pod 'Segmentio', '~> 2.1'
pod 'DeckTransition', '~> 1.0'
end
......
PODFILE CHECKSUM: 3830d742aafcddf8897f50d296bb6ba37c44dc91
PODS:
- DeckTransition (1.2.0)
COCOAPODS: 1.2.0
DEPENDENCIES:
- DeckTransition (~> 1.0)
SPEC CHECKSUMS:
DeckTransition: 23a2c7bdb24bf740a460da72cbb25f9dd28d0a51
PODFILE CHECKSUM: 0d945be0cf7bfa1e45899bb7bbb3730c684345e0
COCOAPODS: 1.2.1
Copyright (c) 2016 Harshil Shah <harshilshah1910@me.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
# DeckTransition
[![CI Status](http://img.shields.io/travis/HarshilShah/DeckTransition.svg)](https://travis-ci.org/HarshilShah/DeckTransition)
[![Version](https://img.shields.io/github/release/HarshilShah/DeckTransition.svg)](https://github.com/HarshilShah/DeckTransition/releases/latest)
[![CocoaPods](https://img.shields.io/badge/CocoaPods-compatible-fb0006.svg)](http://cocoapods.org/pods/DeckTransition)
[![Carthage](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg)](https://github.com/Carthage/Carthage)
[![License](https://img.shields.io/cocoapods/l/DeckTransition.svg)](https://github.com/HarshilShah/DeckTransition/blob/master/LICENSE)
[![Contact](https://img.shields.io/badge/contact-%40HarshilShah1910-3a8fc1.svg)](https://twitter.com/HarshilShah1910)
DeckTransition is an attempt to recreate the card-like transition found in the iOS 10 Apple Music and iMessage apps.
Hereʼs a GIF showing it in action.
![Demo](demo.gif)
## Requirements
- Swift 3
- iOS 9 or later
## Installation
### CocoaPods
To install DeckTransition using [CocoaPods](http://cocoapods.org), add the following line to your Podfile:
```
pod 'DeckTransition', '~> 1.0'
```
### Carthage
To install DeckTransition using [Carthage](https://github.com/Carthage/Carthage), add the following line to your Cartfile:
```
github "HarshilShah/DeckTransition" ~> 1.0
```
## Usage
### Basics
Set `modalPresentationCapturesStatusBarAppearance` to `true` in your modal view controller, and override the `preferredStatusBarStyle` variable to return `.lightContent`.
The background color for the presentation can be changed by changing the `backgroundColor` property of the `window`. This is `.black` by default.
### Presentation
The transition can be called from code or using a storyboard.
To use via storyboards, just setup a custom segue (`kind` set to `custom`), and set the `class` to `DeckSegue`.
Hereʼs a snippet showing usage via code. Just replace `ModalViewController()` with your view controller's class and youʼre good to go.
```swift
let modal = ModalViewController()
let transitionDelegate = DeckTransitioningDelegate()
modal.transitioningDelegate = transitionDelegate
modal.modalPresentationStyle = .custom
present(modal, animated: true, completion: nil)
```
### Dismissal
This is the part where it gets a bit tricky. If youʼve got a fixed-sized i.e. non-scrolling modal, feel free to just skip the rest of this section. Swipe-to-dismiss will work perfectly for you
For modals which have a vertically scrolling layout, the dismissal gesture should be fired only when the view is scrolled to the top. To achieve this behaviour, you need to modify the `isDismissEnabled` property of the `DeckTransitioningDelegate`. (You can also set `isDismissEnabled` to false if you want to disable the swipe-to-dismiss UI.)
The one issue with doing this in response to the scrollviewʼs `contentOffset` is momentum scrolling. When the user pans from top the bottom, once the top of the scrollview is reached (`contentOffset.y` is 0), the dismiss gesture should take over and the scrollview should stop scrolling, not showing the usual iOS bounce effect. The dismiss gesture, however, only responds to pans and not swipes, so should you swipe and not pan, the scrollview will scroll to the top and abruptly stop (as the `contentOffset.y` is 0) without the usual iOS bounce effect.
I've found a temporary workaround for this, the code for this can be found below. Itʼs a bit messy right now, but is the only workaround Iʼve found for this issue (so far). It has one caveat, in that it fails utterly miserably when using with a scrollview whose `backgroundColor` isnʼt `.clear`.
Iʼll update this project if/when I find a better solution.
#### Dismissal code for scrolling modals
First up, make your modal view controller conform to `UIScrollViewDelegate` (or `UITableViewDelegate`/`UITextFieldDelegate`, as the case may be), and assign self as the scrollview's `delegate`.
Next, add this method to your modal view controller, swapping in your scrollviewʼs variable for `textView`.
```swift
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isEqual(textView) else {
return
}
if let delegate = transitioningDelegate as? DeckTransitioningDelegate {
if scrollView.contentOffset.y > 0 {
// Normal behaviour if the `scrollView` isn't scrolled to the top
scrollView.bounces = true
delegate.isDismissEnabled = false
} else {
if scrollView.isDecelerating {
// If the `scrollView` is scrolled to the top but is decelerating
// that means a swipe has been performed. The view and
// scrollviewʼs subviews are both translated in response to this.
view.transform = CGAffineTransform(translationX: 0, y: -scrollView.contentOffset.y)
scrollView.subviews.forEach {
$0.transform = CGAffineTransform(translationX: 0, y: scrollView.contentOffset.y)
}
} else {
// If the user has panned to the top, the scrollview doesnʼt bounce and
// the dismiss gesture is enabled.
scrollView.bounces = false
delegate.isDismissEnabled = true
}
}
}
}
```
## Apps Using DeckTransition
- [Petty](https://zachsim.one/projects/petty) by [Zach Simone](https://twitter.com/zachsimone)
Feel free to submit a PR if you’re using this library in your apps
## Author
Written by Harshil Shah. You can [find me on Twitter](https://twitter.com/HarshilShah1910) if you have any suggestions, requests, or just want to talk!
## License
DeckTransition is available under the MIT license. See the LICENSE file for more info.
//
// Constants.swift
// DeckTransition
//
// Created by Harshil Shah on 04/08/17.
// Copyright © 2017 Harshil Shah. All rights reserved.
//
struct Constants {
/// Default duration for present and dismiss animations when the user hasn't
/// specified one
static let defaultAnimationDuration: TimeInterval = 0.3
/// The corner radius applied to the presenting and presented view
/// controllers's views
static let cornerRadius: CGFloat = 8
/// The alpha value of the presented view controller's view
static let alphaForPresentingView: CGFloat = 0.8
/// As best as I can tell using my iPhone and a bunch of iOS UI templates I
/// came across online, 8 points is the distance between the top edges of
/// the presented and the presenting views
static let insetForPresentedView: CGFloat = 8
}
//
// DeckDismissingAnimationController.swift
// DeckTransition
//
// Created by Harshil Shah on 15/10/16.
// Copyright © 2016 Harshil Shah. All rights reserved.
//
import UIKit
final class DeckDismissingAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
// MARK:- Private variables
private let duration: TimeInterval?
private let animation: (() -> ())?
private let completion: ((Bool) -> ())?
// MARK:- Initializers
init(duration: TimeInterval?, animation: (() -> ())?, completion: ((Bool) -> ())?) {
self.duration = duration
self.animation = animation
self.completion = completion
}
// MARK:- UIViewControllerAnimatedTransitioning
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let presentingViewController = transitionContext.viewController(forKey: .to)!
let presentedViewController = transitionContext.viewController(forKey: .from)!
let containerView = transitionContext.containerView
let roundedViewForPresentingView = RoundedView()
roundedViewForPresentingView.cornerRadius = Constants.cornerRadius
roundedViewForPresentingView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(roundedViewForPresentingView)
/// At the end of the transition the rounded view has to have the same
/// frame as the presentingView, except with a height equal to the
/// cornerRadius
let finalFrameForPresentingView = transitionContext.finalFrame(for: presentingViewController)
let finalFrameForRoundedViewForPresentingView = CGRect(
x: finalFrameForPresentingView.origin.x,
y: finalFrameForPresentingView.origin.y,
width: finalFrameForPresentingView.width,
height: Constants.cornerRadius)
roundedViewForPresentingView.frame = finalFrameForRoundedViewForPresentingView
let scale: CGFloat = 1 - (ManualLayout.presentingViewTopInset * 2 / finalFrameForPresentingView.height)
/// The rounded view needs to be scaled by the same amount as the
/// presentingView, and also translated down by the same amount.
/// Scaling happens with respect to the frame's center, so a
/// translate-scale-translate needs to be done to ensure that the
/// scaling is performed with respect to the top edge so it still lines
/// up with the top edge of the presentingView
let transformForRoundedViewForPresentingView = CGAffineTransform.identity
.translatedBy(x: 0, y: ManualLayout.presentingViewTopInset)
.translatedBy(x: 0, y: -finalFrameForRoundedViewForPresentingView.height / 2)
.scaledBy(x: scale, y: scale)
.translatedBy(x: 0, y: finalFrameForRoundedViewForPresentingView.height / 2)
roundedViewForPresentingView.transform = transformForRoundedViewForPresentingView
let offScreenFrame = CGRect(x: 0, y: containerView.bounds.height, width: containerView.bounds.width, height: containerView.bounds.height)
UIView.animate(
withDuration: transitionDuration(using: transitionContext),
delay: 0,
options: .curveEaseOut,
animations: { [weak self] in
roundedViewForPresentingView.transform = .identity
presentingViewController.view.alpha = 1
presentingViewController.view.transform = .identity
presentedViewController.view.frame = offScreenFrame
self?.animation?()
}, completion: { [weak self] finished in
roundedViewForPresentingView.removeFromSuperview()
transitionContext.completeTransition(finished)
self?.completion?(finished)
})
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration ?? Constants.defaultAnimationDuration
}
}
//
// DeckPresentationController.swift
// DeckTransition
//
// Created by Harshil Shah on 15/10/16.
// Copyright © 2016 Harshil Shah. All rights reserved.
//
import UIKit
/// Delegate that communicates to the `DeckPresentationController` whether the
/// dismiss by pan gesture is enabled
protocol DeckPresentationControllerDelegate {
func isDismissGestureEnabled() -> Bool
}
final class DeckPresentationController: UIPresentationController, UIGestureRecognizerDelegate {
// MARK:- Internal variables
var transitioningDelegate: DeckPresentationControllerDelegate?
// MARK:- Private variables
private var pan: UIPanGestureRecognizer?
private var roundedViewForPresentingView: RoundedView?
private var roundedViewForPresentedView: RoundedView?
private var backgroundView: UIView?
private var cachedContainerWidth: CGFloat = 0
private var presentingViewSnapshotView: UIView?
private var snapshotViewHeightConstraint: NSLayoutConstraint?
private var snapshotViewAspectRatioConstraint: NSLayoutConstraint?
private var presentAnimation: (() -> ())? = nil
private var presentCompletion: ((Bool) -> ())? = nil
private var dismissAnimation: (() -> ())? = nil
private var dismissCompletion: ((Bool) -> ())? = nil
// MARK:- Initializers
convenience init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, presentAnimation: (() -> ())? = nil, presentCompletion: ((Bool) ->())? = nil, dismissAnimation: (() -> ())? = nil, dismissCompletion: ((Bool) -> ())? = nil) {
self.init(presentedViewController: presentedViewController, presenting: presentingViewController)
self.presentAnimation = presentAnimation
self.presentCompletion = presentCompletion
self.dismissAnimation = dismissAnimation
self.dismissCompletion = dismissCompletion
NotificationCenter.default.addObserver(self, selector: #selector(updateForStatusBar), name: .UIApplicationDidChangeStatusBarFrame, object: nil)
}
// MARK:- Sizing
var statusBarHeight: CGFloat {
return UIApplication.shared.statusBarFrame.height
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView = containerView else {
return .zero
}
let yOffset = ManualLayout.presentingViewTopInset + Constants.insetForPresentedView
return CGRect(x: 0,
y: yOffset,
width: containerView.bounds.width,
height: containerView.bounds.height - yOffset)
}
// MARK:- Presentation
/// Method to ensure the layout is as required at the end of the
/// presentation. This is required in case the modal is presented without
/// animation.
///
/// The various layout related functions performed by this method are:
/// - Ensure that the view is in the same state as it would be after
/// animated presentation
/// - Create and add the `presentingViewSnapshotView` to the view hierarchy
/// - Add a black background view to present to complete cover the
/// `presentingViewController`'s view
/// - Reset the `presentingViewController`'s view's `transform` so that
/// further layout updates (such as status bar update) do not break the
/// transform
///
/// It also sets up the gesture recognizer to handle dismissal of the modal
/// view controller by panning downwards
override func presentationTransitionDidEnd(_ completed: Bool) {
guard let containerView = containerView else {
return
}
if completed {
roundedViewForPresentedView = RoundedView()
roundedViewForPresentedView!.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(roundedViewForPresentedView!)
presentedViewController.view.addObserver(self, forKeyPath: "frame", options: [.initial], context: nil)
presentedViewController.view.addObserver(self, forKeyPath: "transform", options: [.initial], context: nil)
presentedViewController.view.frame = frameOfPresentedViewInContainerView
presentAnimation?()
presentingViewSnapshotView = UIView()
presentingViewSnapshotView!.translatesAutoresizingMaskIntoConstraints = false
containerView.insertSubview(presentingViewSnapshotView!, belowSubview: presentedViewController.view)
NSLayoutConstraint.activate([
presentingViewSnapshotView!.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
presentingViewSnapshotView!.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
])
updateSnapshotView()
roundedViewForPresentingView = RoundedView()
roundedViewForPresentingView!.translatesAutoresizingMaskIntoConstraints = false
containerView.insertSubview(roundedViewForPresentingView!, aboveSubview: presentingViewSnapshotView!)
NSLayoutConstraint.activate([
roundedViewForPresentingView!.topAnchor.constraint(equalTo: presentingViewSnapshotView!.topAnchor),
roundedViewForPresentingView!.leftAnchor.constraint(equalTo: presentingViewSnapshotView!.leftAnchor),
roundedViewForPresentingView!.rightAnchor.constraint(equalTo: presentingViewSnapshotView!.rightAnchor),
roundedViewForPresentingView!.heightAnchor.constraint(equalToConstant: Constants.cornerRadius)
])
backgroundView = UIView()
backgroundView!.backgroundColor = .black
backgroundView!.translatesAutoresizingMaskIntoConstraints = false
containerView.insertSubview(backgroundView!, belowSubview: presentingViewSnapshotView!)
NSLayoutConstraint.activate([
backgroundView!.topAnchor.constraint(equalTo: containerView.window!.topAnchor),
backgroundView!.leftAnchor.constraint(equalTo: containerView.window!.leftAnchor),
backgroundView!.rightAnchor.constraint(equalTo: containerView.window!.rightAnchor),
backgroundView!.bottomAnchor.constraint(equalTo: containerView.window!.bottomAnchor)
])
presentingViewController.view.transform = .identity
pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
pan!.delegate = self
pan!.maximumNumberOfTouches = 1
pan!.cancelsTouchesInView = false
presentedViewController.view.addGestureRecognizer(pan!)
}
presentCompletion?(completed)
}
// MARK:- Layout update methods
/// This method updates the aspect ratio of the snapshot view
///
/// The `snapshotView`'s aspect ratio needs to be updated here because even
/// though it is updated with the `snapshotView` in `viewWillTransition:`,
/// the transition is janky unless it's updated before, hence it's performed
/// here as well, It's also an inexpensive method since constraints are
/// modified only when a change is actually needed
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
updateSnapshotViewAspectRatio()
if let roundedView = roundedViewForPresentedView {
containerView?.bringSubview(toFront: roundedView)
}
UIView.animate(withDuration: 0.1) { [weak self] in
guard let `self` = self else { return }
self.presentedViewController.view.frame = self.frameOfPresentedViewInContainerView
}
}
/// Method to handle the modal setup's response to a change in
/// orientation, size, etc.
///
/// Everything else is handled by AutoLayout or `willLayoutSubviews`; the
/// express purpose of this method is to update the snapshot view since that
/// is a relatively expensive operation and only makes sense on orientation
/// change
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(
alongsideTransition: nil,
completion: { [weak self] _ in
self?.updateSnapshotView()
}
)
}
/// Method to handle updating the view when the status bar's height changes
///
/// The `containerView`'s frame is always supposed to be the go 20 pixels
/// or 1 normal status bar height under the status bar itself, even when the
/// status bar is of double height, to retain consistency with the system's
/// default behaviour
///
/// The containerView is the only thing that received layout updates;
/// AutoLayout and the snapshotView method handle the rest. Additionally,
/// the mask for the `presentedViewController` is also reset
@objc func updateForStatusBar() {
guard let containerView = containerView else {
return
}
/// The `presentingViewController.view` often animated "before" the mask
/// view that should fully cover it, so it's hidden before altering the
/// view hierarchy, and then revealed after the animations are finished
presentingViewController.view.alpha = 0
let fullHeight = containerView.window!.frame.size.height
let currentHeight = containerView.frame.height
let newHeight = fullHeight - ManualLayout.containerViewTopInset
UIView.animate(
withDuration: 0.1,
animations: {
containerView.frame.origin.y -= newHeight - currentHeight
}, completion: { [weak self] _ in
self?.presentingViewController.view.alpha = Constants.alphaForPresentingView
containerView.frame = CGRect(x: 0, y: ManualLayout.containerViewTopInset, width: containerView.frame.width, height: newHeight)
}
)
}
// MARK:- Snapshot view update methods
/// Method to update the snapshot view showing a representation of the
/// `presentingViewController`'s view
///
/// The method can only be fired when the snapshot view has been set up, and
/// then only when the width of the container is updated
///
/// It resets the aspect ratio constraint for the snapshot view first, and
/// then generates a new snapshot of the `presentingViewController`'s view,
/// and then replaces the existing snapshot with it
private func updateSnapshotView() {
guard let presentingViewSnapshotView = presentingViewSnapshotView else {
return
}
updateSnapshotViewAspectRatio()
if let snapshotView = presentingViewController.view.snapshotView(afterScreenUpdates: true) {
presentingViewSnapshotView.subviews.forEach { $0.removeFromSuperview() }
snapshotView.translatesAutoresizingMaskIntoConstraints = false
presentingViewSnapshotView.addSubview(snapshotView)
NSLayoutConstraint.activate([
snapshotView.topAnchor.constraint(equalTo: presentingViewSnapshotView.topAnchor),
snapshotView.leftAnchor.constraint(equalTo: presentingViewSnapshotView.leftAnchor),
snapshotView.rightAnchor.constraint(equalTo: presentingViewSnapshotView.rightAnchor),
snapshotView.bottomAnchor.constraint(equalTo: presentingViewSnapshotView.bottomAnchor)
])
}
}
/// Thie method updates the aspect ratio and the height of the snapshot view
/// used to represent the presenting view controller.
///
/// The aspect ratio is only updated when the width of the container changes
/// i.e. when just the status bar moves, nothing happens
private func updateSnapshotViewAspectRatio() {
guard let containerView = containerView,
let presentingViewSnapshotView = presentingViewSnapshotView,
cachedContainerWidth != containerView.bounds.width
else {
return
}
cachedContainerWidth = containerView.bounds.width
snapshotViewHeightConstraint?.isActive = false
snapshotViewAspectRatioConstraint?.isActive = false
let heightConstant = ManualLayout.presentingViewTopInset * -2
let aspectRatio = containerView.bounds.width / containerView.bounds.height
roundedViewForPresentingView?.cornerRadius = Constants.cornerRadius * (1 - (heightConstant / containerView.frame.height))
snapshotViewHeightConstraint = presentingViewSnapshotView.heightAnchor.constraint(equalTo: containerView.heightAnchor,constant: heightConstant)
snapshotViewAspectRatioConstraint = presentingViewSnapshotView.widthAnchor.constraint(equalTo: presentingViewSnapshotView.heightAnchor, multiplier: aspectRatio)
snapshotViewHeightConstraint?.isActive = true
snapshotViewAspectRatioConstraint?.isActive = true
}
// MARK:- Presented view KVO + Rounded view update methods
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "transform" || keyPath == "frame", let view = object as? UIView {
let offset = view.frame.origin.y
updateRoundedView(forOffset: offset)
}
}