DeckPresentationController.swift 28.2 KB
Newer Older
Zach Knox's avatar
Zach Knox committed
1
2
3
4
5
6
7
8
9
10
//
//  DeckPresentationController.swift
//  DeckTransition
//
//  Created by Harshil Shah on 15/10/16.
//  Copyright © 2016 Harshil Shah. All rights reserved.
//

import UIKit

Zach Knox's avatar
Zach Knox committed
11
final class DeckPresentationController: UIPresentationController, UIGestureRecognizerDelegate, DeckSnapshotUpdater {
12
13
14
    
    // MARK: - Internal variables
    
Zach Knox's avatar
Zach Knox committed
15
16
17
18
19
20
21
22
23
    /// The presentation controller holds a strong reference to the
    /// transitioning delegate because `UIViewController.transitioningDelegate`
    /// is a weak property, and thus the `DeckTransitioningDelegate` would be
    /// unallocated right after the presentation animation.
    ///
    /// Since the transitioningDelegate only vends the presentation controller
    /// object and does not hold a reference to it, there is no issue of a
    /// circular dependency here.
    var transitioningDelegate: DeckTransitioningDelegate?
24
25
    
    // MARK: - Private variables
Zach Knox's avatar
Zach Knox committed
26
    
Zach Knox's avatar
Zach Knox committed
27
    private var isSwipeToDismissGestureEnabled = true
Zach Knox's avatar
Zach Knox committed
28
    private var pan: UIPanGestureRecognizer?
Zach Knox's avatar
Zach Knox committed
29
    private var scrollViewUpdater: ScrollViewUpdater?
Zach Knox's avatar
Zach Knox committed
30
    
Zach Knox's avatar
Zach Knox committed
31
32
33
34
    private let backgroundView = UIView()
    private let roundedViewForPresentingView = RoundedView()
    private let roundedViewForPresentedView = RoundedView()
    
35
36
37
    private let snapshotViewContainer = UIView()
    private var snapshotView: UIView?
    
Zach Knox's avatar
Zach Knox committed
38
39
    private var snapshotViewTopConstraint: NSLayoutConstraint?
    private var snapshotViewWidthConstraint: NSLayoutConstraint?
40
    private var snapshotViewAspectRatioConstraint: NSLayoutConstraint?
Zach Knox's avatar
Zach Knox committed
41
42
43
    
    private var presentedViewFrameObserver: NSKeyValueObservation?
    private var presentedViewTransformObserver: NSKeyValueObservation?
44
45
46
47
48
    
    private var presentAnimation: (() -> ())? = nil
    private var presentCompletion: ((Bool) -> ())? = nil
    private var dismissAnimation: (() -> ())? = nil
    private var dismissCompletion: ((Bool) -> ())? = nil
Zach Knox's avatar
Zach Knox committed
49
	
50
    // MARK: - Initializers
Zach Knox's avatar
Zach Knox committed
51
    
52
53
    convenience init(presentedViewController: UIViewController,
                     presenting presentingViewController: UIViewController?,
Zach Knox's avatar
Zach Knox committed
54
                     isSwipeToDismissGestureEnabled: Bool,
55
56
57
58
59
60
61
                     presentAnimation: (() -> ())? = nil,
                     presentCompletion: ((Bool) ->())? = nil,
                     dismissAnimation: (() -> ())? = nil,
                     dismissCompletion: ((Bool) -> ())? = nil) {
        self.init(presentedViewController: presentedViewController,
                  presenting: presentingViewController)
        
Zach Knox's avatar
Zach Knox committed
62
        self.isSwipeToDismissGestureEnabled = isSwipeToDismissGestureEnabled
63
64
65
66
67
        self.presentAnimation = presentAnimation
        self.presentCompletion = presentCompletion
        self.dismissAnimation = dismissAnimation
        self.dismissCompletion = dismissCompletion
        
68
        NotificationCenter.default.addObserver(self, selector: #selector(updateForStatusBar), name: UIApplication.didChangeStatusBarFrameNotification, object: nil)
69
70
71
    }

    // MARK: - Public methods
Zach Knox's avatar
Zach Knox committed
72
73
74
75
76
    
    public func requestPresentedViewSnapshotUpdate() {
        updateSnapshotView()
    }
    
77
    // MARK: - Sizing
Zach Knox's avatar
Zach Knox committed
78
    
Zach Knox's avatar
Zach Knox committed
79
    private var statusBarHeight: CGFloat {
Zach Knox's avatar
Zach Knox committed
80
81
        return UIApplication.shared.statusBarFrame.height
    }
Zach Knox's avatar
Zach Knox committed
82
83
84
85
86
87
88
89
    
    private var scaleForPresentingView: CGFloat {
        guard let containerView = containerView else {
            return 0
        }
        
        return 1 - (ManualLayout.presentingViewTopInset * 2 / containerView.frame.height)
    }
Zach Knox's avatar
Zach Knox committed
90
91
92
93
94
95
96
97
98
99
100
101
102
103
	
    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)
    }
	
104
	// MARK: - Presentation
Zach Knox's avatar
Zach Knox committed
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
    
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView, let window = containerView.window else {
            return
        }
        
        /// A CGRect to be used as a proxy for the frame of the presentingView
        ///
        /// The actual frame isn't used directly because in the case of the
        /// double height status bar on non-X iPhones, the containerView has a
        /// reduced height
        let initialFrame: CGRect = {
            if presentingViewController.isPresentedWithDeck {
                return presentingViewController.view.frame
            } else {
                return containerView.bounds
            }
        }()
        
        /// The presented view's rounded view's frame is updated using KVO
        roundedViewForPresentedView.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(roundedViewForPresentedView)
        setupPresentedViewKVO()
        
        /// The snapshot view initially has the same frame as the presentingView
130
131
        containerView.insertSubview(snapshotViewContainer, belowSubview: presentedViewController.view)
        snapshotViewContainer.frame = initialFrame
Zach Knox's avatar
Zach Knox committed
132
133
134
135
136
137
138
139
140
141
142
143
144
        updateSnapshotView()
        
        /// The following transforms are performed on the snapshot view:
        /// 1. It's frame's origin is reset to 0. This is done because for
        ///    recursive Deck modals, the reference frame will not have its
        ///    origin at `.zero`
        /// 2. It is translated down by `ManualLayout.presentingViewTopInset`
        ///    points This is the desired inset from the top of the
        ///    containerView
        /// 3. It is scaled down by `scaleForPresentingView` along both axes,
        ///    such that it's top edge is at the same position. In order to do
        ///    this, we translate it up by half it's height, perform the
        ///    scaling, and then translate it back down by the same amount
145
146
147
148
149
150
151
152
153
        ///
        /// On versions of iOS before 11, applying a transform to the parent
        /// view modifies the frame of subviews immediately to the model value,
        /// so this transform is applied directly to the snapshot view and not
        /// the snapshot container view
        ///
        /// Note: For some reason, this behaviour only happens in the
        /// presentation phase; constraints animate as expected on iOS <11 in
        /// the dismissal 🤷🏽‍♂️
Zach Knox's avatar
Zach Knox committed
154
        let transformForSnapshotView = CGAffineTransform.identity
155
            .translatedBy(x: 0, y: -snapshotViewContainer.frame.origin.y)
Zach Knox's avatar
Zach Knox committed
156
            .translatedBy(x: 0, y: ManualLayout.presentingViewTopInset)
157
            .translatedBy(x: 0, y: -snapshotViewContainer.frame.height / 2)
Zach Knox's avatar
Zach Knox committed
158
            .scaledBy(x: scaleForPresentingView, y: scaleForPresentingView)
159
            .translatedBy(x: 0, y: snapshotViewContainer.frame.height / 2)
Zach Knox's avatar
Zach Knox committed
160
161
162
163
164
        
        /// For a recursive modal, the `presentingView` already has rounded
        /// corners so the animation must respect that
        roundedViewForPresentingView.backgroundColor = UIColor.black.withAlphaComponent(0)
        roundedViewForPresentingView.cornerRadius = presentingViewController.isPresentedWithDeck ? Constants.cornerRadius : 0
165
        containerView.insertSubview(roundedViewForPresentingView, aboveSubview: snapshotViewContainer)
Zach Knox's avatar
Zach Knox committed
166
167
168
169
170
        roundedViewForPresentingView.frame = initialFrame
        
        /// The background view is used to cover up the `presentedView`
        backgroundView.backgroundColor = .black
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
171
        containerView.insertSubview(backgroundView, belowSubview: snapshotViewContainer)
Zach Knox's avatar
Zach Knox committed
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
        
        NSLayoutConstraint.activate([
            backgroundView.topAnchor.constraint(equalTo: window.topAnchor),
            backgroundView.leftAnchor.constraint(equalTo: window.leftAnchor),
            backgroundView.rightAnchor.constraint(equalTo: window.rightAnchor),
            backgroundView.bottomAnchor.constraint(equalTo: window.bottomAnchor)
        ])
        
        /// A snapshot view is used to represent the hierarchy of cards in the
        /// case of recursive presentation
        var rootSnapshotView: UIView?
        var rootSnapshotRoundedView: RoundedView?
        
        if presentingViewController.isPresentedWithDeck {
            guard let rootController = presentingViewController.presentingViewController,
                  let snapshotView = rootController.view.snapshotView(afterScreenUpdates: false)
            else {
                return
            }
            
            containerView.insertSubview(snapshotView, aboveSubview: backgroundView)
            snapshotView.frame = initialFrame
            snapshotView.transform = transformForSnapshotView
            snapshotView.alpha = Constants.alphaForPresentingView
            rootSnapshotView = snapshotView
            
            let snapshotRoundedView = RoundedView()
            snapshotRoundedView.cornerRadius = Constants.cornerRadius
            containerView.insertSubview(snapshotRoundedView, aboveSubview: snapshotView)
            snapshotRoundedView.frame = initialFrame
            snapshotRoundedView.transform = transformForSnapshotView
            rootSnapshotRoundedView = snapshotRoundedView
        }
        
        presentedViewController.transitionCoordinator?.animate(
207
208
209
210
211
            alongsideTransition: { [weak self] context in
                guard let `self` = self else {
                    return
                }
                
Zach Knox's avatar
Zach Knox committed
212
                self.presentAnimation?()
213
                self.snapshotView?.transform = transformForSnapshotView
Zach Knox's avatar
Zach Knox committed
214
215
216
217
                self.roundedViewForPresentingView.cornerRadius = Constants.cornerRadius
                self.roundedViewForPresentingView.transform = transformForSnapshotView
                self.roundedViewForPresentingView.backgroundColor = UIColor.black.withAlphaComponent(1 - Constants.alphaForPresentingView)
            }, completion: { _ in
218
                self.snapshotView?.transform = .identity
Zach Knox's avatar
Zach Knox committed
219
220
221
222
223
                rootSnapshotView?.removeFromSuperview()
                rootSnapshotRoundedView?.removeFromSuperview()
            }
        )
    }
224
    
Zach Knox's avatar
Zach Knox committed
225
    /// Method to ensure the layout is as required at the end of the
226
227
228
229
230
231
232
233
234
235
236
237
    /// 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
Zach Knox's avatar
Zach Knox committed
238
239
    ///
    /// It also sets up the gesture recognizer to handle dismissal of the modal
240
    /// view controller by panning downwards
Zach Knox's avatar
Zach Knox committed
241
    override func presentationTransitionDidEnd(_ completed: Bool) {
242
243
244
        guard let containerView = containerView else {
            return
        }
Zach Knox's avatar
Zach Knox committed
245
246
247
        
        presentedViewController.view.frame = frameOfPresentedViewInContainerView
        
248
249
250
        snapshotViewContainer.transform = .identity
        snapshotViewContainer.translatesAutoresizingMaskIntoConstraints = false
        snapshotViewContainer.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true
Zach Knox's avatar
Zach Knox committed
251
252
253
254
255
        updateSnapshotViewAspectRatio()
        
        roundedViewForPresentingView.transform = .identity
        roundedViewForPresentingView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
256
257
258
259
            roundedViewForPresentingView.topAnchor.constraint(equalTo: snapshotViewContainer.topAnchor),
            roundedViewForPresentingView.leftAnchor.constraint(equalTo: snapshotViewContainer.leftAnchor),
            roundedViewForPresentingView.rightAnchor.constraint(equalTo: snapshotViewContainer.rightAnchor),
            roundedViewForPresentingView.bottomAnchor.constraint(equalTo: snapshotViewContainer.bottomAnchor)
Zach Knox's avatar
Zach Knox committed
260
261
        ])
        
Zach Knox's avatar
Zach Knox committed
262
263
264
265
266
267
268
        if isSwipeToDismissGestureEnabled {
            pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
            pan!.delegate = self
            pan!.maximumNumberOfTouches = 1
            pan!.cancelsTouchesInView = false
            presentedViewController.view.addGestureRecognizer(pan!)
        }
269
270

        presentCompletion?(completed)
Zach Knox's avatar
Zach Knox committed
271
272
    }
	
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
    // 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()
        
        guard let containerView = containerView else {
            return
        }
Zach Knox's avatar
Zach Knox committed
288
        
Zach Knox's avatar
Zach Knox committed
289
        updateSnapshotViewAspectRatio()
290
        containerView.bringSubviewToFront(roundedViewForPresentedView)
Zach Knox's avatar
Zach Knox committed
291
        
292
293
294
295
296
297
298
299
        if presentedViewController.view.isDescendant(of: containerView) {
            UIView.animate(withDuration: 0.1) { [weak self] in
                guard let `self` = self else {
                    return
                }
                
                self.presentedViewController.view.frame = self.frameOfPresentedViewInContainerView
            }
Zach Knox's avatar
Zach Knox committed
300
        }
301
    }
Zach Knox's avatar
Zach Knox committed
302
303
    
    /// Method to handle the modal setup's response to a change in
304
305
306
307
308
309
    /// 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
Zach Knox's avatar
Zach Knox committed
310
311
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
312
313
314
315
        
        coordinator.animate(
            alongsideTransition: nil,
            completion: { [weak self] _ in
Zach Knox's avatar
Zach Knox committed
316
                self?.updateSnapshotViewAspectRatio()
317
318
                self?.updateSnapshotView()
            }
Zach Knox's avatar
Zach Knox committed
319
320
321
        )
    }
	
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
    /// 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 private 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
Zach Knox's avatar
Zach Knox committed
340
        presentingViewController.view.alpha = 0
341
342
343
344
345
346
347
348
349
350
351
        
        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
Zach Knox's avatar
Zach Knox committed
352
                self?.presentingViewController.view.alpha = 1
Zach Knox's avatar
Zach Knox committed
353
                containerView.frame = CGRect(x: 0, y: ManualLayout.containerViewTopInset, width: containerView.frame.width, height: newHeight)
Zach Knox's avatar
Zach Knox committed
354
                self?.updateSnapshotView()
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
            }
        )
    }
    
    // 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 currentSnapshotView = presentingViewController.view.snapshotView(afterScreenUpdates: true) else {
            return
        }
        
        snapshotView?.removeFromSuperview()
        
        currentSnapshotView.translatesAutoresizingMaskIntoConstraints = false
        snapshotViewContainer.addSubview(currentSnapshotView)
        NSLayoutConstraint.activate([
            currentSnapshotView.topAnchor.constraint(equalTo: snapshotViewContainer.topAnchor),
            currentSnapshotView.leftAnchor.constraint(equalTo: snapshotViewContainer.leftAnchor),
            currentSnapshotView.rightAnchor.constraint(equalTo: snapshotViewContainer.rightAnchor),
            currentSnapshotView.bottomAnchor.constraint(equalTo: snapshotViewContainer.bottomAnchor)
        ])
        
        snapshotView = currentSnapshotView
Zach Knox's avatar
Zach Knox committed
387
388
	}
	
389
    /// Thie method updates the aspect ratio and the height of the snapshot view
Zach Knox's avatar
Zach Knox committed
390
    /// used to represent the presenting view controller.
391
392
393
    ///
    /// The aspect ratio is only updated when the width of the container changes
    /// i.e. when just the status bar moves, nothing happens
Zach Knox's avatar
Zach Knox committed
394
    private func updateSnapshotViewAspectRatio() {
395
396
397
398
399
        guard let containerView = containerView,
              snapshotViewContainer.translatesAutoresizingMaskIntoConstraints == false
        else {
            return
        }
Zach Knox's avatar
Zach Knox committed
400
        
Zach Knox's avatar
Zach Knox committed
401
402
        snapshotViewTopConstraint?.isActive = false
        snapshotViewWidthConstraint?.isActive = false
403
        snapshotViewAspectRatioConstraint?.isActive = false
Zach Knox's avatar
Zach Knox committed
404
        
Zach Knox's avatar
Zach Knox committed
405
406
407
408
        let snapshotReferenceSize = presentingViewController.view.frame.size
        
        let topInset = ManualLayout.presentingViewTopInset
        
409
        let aspectRatio = snapshotReferenceSize.width / snapshotReferenceSize.height
Zach Knox's avatar
Zach Knox committed
410
        
Zach Knox's avatar
Zach Knox committed
411
412
        roundedViewForPresentingView.cornerRadius = Constants.cornerRadius * scaleForPresentingView
        
413
414
415
        snapshotViewTopConstraint = snapshotViewContainer.topAnchor.constraint(equalTo: containerView.topAnchor, constant: topInset)
        snapshotViewWidthConstraint = snapshotViewContainer.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: scaleForPresentingView)
        snapshotViewAspectRatioConstraint = snapshotViewContainer.widthAnchor.constraint(equalTo: snapshotViewContainer.heightAnchor, multiplier: aspectRatio)
Zach Knox's avatar
Zach Knox committed
416
417
418
        
        snapshotViewTopConstraint?.isActive = true
        snapshotViewWidthConstraint?.isActive = true
Zach Knox's avatar
Zach Knox committed
419
        snapshotViewAspectRatioConstraint?.isActive = true
420
421
422
423
    }
    
    // MARK: - Presented view KVO + Rounded view update methods
    
Zach Knox's avatar
Zach Knox committed
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
    private func setupPresentedViewKVO() {
        presentedViewFrameObserver = presentedViewController.view.observe(\.frame, options: [.initial]) { [weak self] _, _ in
            self?.presentedViewWasUpdated()
        }
        
        presentedViewTransformObserver = presentedViewController.view.observe(\.transform, options: [.initial]) { [weak self] _, _ in
            self?.presentedViewWasUpdated()
        }
    }
    
    private func invalidatePresentedViewKVO() {
        presentedViewFrameObserver = nil
        presentedViewTransformObserver = nil
    }
    
    private func presentedViewWasUpdated() {
        let offset = presentedViewController.view.frame.origin.y
441
442
443
444
445
446
447
448
449
450
451
452
        roundedViewForPresentedView.frame = CGRect(x: 0, y: offset, width: containerView!.bounds.width, height: Constants.cornerRadius)
    }
    
    // MARK: - Dismissal
    
    /// Method to prepare the view hirarchy for the dismissal animation
    ///
    /// The stuff with snapshots and the black background should be invisible to
    /// the dismissal animation, so this method effectively removes them and
    /// restores the state of the `presentingViewController`'s view to the
    /// expected state at the end of the presenting animation
    override func dismissalTransitionWillBegin() {
Zach Knox's avatar
Zach Knox committed
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
        guard let containerView = containerView else {
            return
        }
        
        let initialFrame: CGRect = {
            if presentingViewController.isPresentedWithDeck {
                return presentingViewController.view.frame
            } else {
                return containerView.bounds
            }
        }()
        
        let initialTransform = CGAffineTransform.identity
            .translatedBy(x: 0, y: -initialFrame.origin.y)
            .translatedBy(x: 0, y: ManualLayout.presentingViewTopInset)
            .translatedBy(x: 0, y: -initialFrame.height / 2)
            .scaledBy(x: scaleForPresentingView, y: scaleForPresentingView)
            .translatedBy(x: 0, y: initialFrame.height / 2)
        
        roundedViewForPresentingView.translatesAutoresizingMaskIntoConstraints = true
        roundedViewForPresentingView.frame = initialFrame
        roundedViewForPresentingView.transform = initialTransform
        
        snapshotViewTopConstraint?.isActive = false
        snapshotViewWidthConstraint?.isActive = false
        snapshotViewAspectRatioConstraint?.isActive = false
479
480
481
        snapshotViewContainer.translatesAutoresizingMaskIntoConstraints = true
        snapshotViewContainer.frame = initialFrame
        snapshotViewContainer.transform = initialTransform
Zach Knox's avatar
Zach Knox committed
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
        
        let finalCornerRadius = presentingViewController.isPresentedWithDeck ? Constants.cornerRadius : 0
        let finalTransform: CGAffineTransform = .identity
        
        var rootSnapshotView: UIView?
        var rootSnapshotRoundedView: RoundedView?
        
        if presentingViewController.isPresentedWithDeck {
            guard let rootController = presentingViewController.presentingViewController,
                  let snapshotView = rootController.view.snapshotView(afterScreenUpdates: false)
            else {
                return
            }
            
            containerView.insertSubview(snapshotView, aboveSubview: backgroundView)
            snapshotView.frame = initialFrame
            snapshotView.transform = initialTransform
            rootSnapshotView = snapshotView
            
            let snapshotRoundedView = RoundedView()
            snapshotRoundedView.cornerRadius = Constants.cornerRadius
            snapshotRoundedView.backgroundColor = UIColor.black.withAlphaComponent(1 - Constants.alphaForPresentingView)
            containerView.insertSubview(snapshotRoundedView, aboveSubview: snapshotView)
            snapshotRoundedView.frame = initialFrame
            snapshotRoundedView.transform = initialTransform
            rootSnapshotRoundedView = snapshotRoundedView
        }
        
        presentedViewController.transitionCoordinator?.animate(
511
512
513
514
515
            alongsideTransition: { [weak self] context in
                guard let `self` = self else {
                    return
                }
                
Zach Knox's avatar
Zach Knox committed
516
                self.dismissAnimation?()
517
                self.snapshotViewContainer.transform = finalTransform
Zach Knox's avatar
Zach Knox committed
518
519
520
521
522
523
524
525
                self.roundedViewForPresentingView.transform = finalTransform
                self.roundedViewForPresentingView.cornerRadius = finalCornerRadius
                self.roundedViewForPresentingView.backgroundColor = .clear
            }, completion: { _ in
                rootSnapshotView?.removeFromSuperview()
                rootSnapshotRoundedView?.removeFromSuperview()
            }
        )
526
527
528
529
530
    }
    
    /// Method to ensure the layout is as required at the end of the dismissal.
    /// This is required in case the modal is dismissed without animation.
    override func dismissalTransitionDidEnd(_ completed: Bool) {
Zach Knox's avatar
Zach Knox committed
531
532
533
534
        guard let containerView = containerView else {
            return
        }
        
535
536
537
        backgroundView.removeFromSuperview()
        snapshotView?.removeFromSuperview()
        snapshotViewContainer.removeFromSuperview()
Zach Knox's avatar
Zach Knox committed
538
539
540
541
542
543
544
        roundedViewForPresentingView.removeFromSuperview()
        
        let offscreenFrame = CGRect(x: 0, y: containerView.bounds.height, width: containerView.bounds.width, height: containerView.bounds.height)
        presentedViewController.view.frame = offscreenFrame
        presentedViewController.view.transform = .identity
        
        invalidatePresentedViewKVO()
545
546
547
548
549
550
        
        dismissCompletion?(completed)
    }
    
    // MARK: - Gesture handling
    
Zach Knox's avatar
Zach Knox committed
551
552
553
554
555
556
557
558
    private func isSwipeToDismissAllowed() -> Bool {
        guard let updater = scrollViewUpdater else {
            return isSwipeToDismissGestureEnabled
        }
        
        return updater.isDismissEnabled
    }
    
Zach Knox's avatar
Zach Knox committed
559
    @objc private func handlePan(gestureRecognizer: UIPanGestureRecognizer) {
Zach Knox's avatar
Zach Knox committed
560
        guard gestureRecognizer.isEqual(pan), isSwipeToDismissGestureEnabled else {
Zach Knox's avatar
Zach Knox committed
561
562
563
564
565
566
            return
        }
        
        switch gestureRecognizer.state {
        
        case .began:
Zach Knox's avatar
Zach Knox committed
567
568
569
570
571
572
            let detector = ScrollViewDetector(withViewController: presentedViewController)
            if let scrollView = detector.scrollView {
                scrollViewUpdater = ScrollViewUpdater(
                    withRootView: presentedViewController.view,
                    scrollView: scrollView)
            }
Zach Knox's avatar
Zach Knox committed
573
574
575
            gestureRecognizer.setTranslation(CGPoint(x: 0, y: 0), in: containerView)
        
        case .changed:
Zach Knox's avatar
Zach Knox committed
576
577
578
579
580
            if isSwipeToDismissAllowed() {
                let translation = gestureRecognizer.translation(in: presentedView)
                updatePresentedViewForTranslation(inVerticalDirection: translation.y)
            } else {
                gestureRecognizer.setTranslation(.zero, in: presentedView)
Zach Knox's avatar
Zach Knox committed
581
582
583
584
585
586
587
            }
        
        case .ended:
            UIView.animate(
                withDuration: 0.25,
                animations: {
                    self.presentedView?.transform = .identity
588
                })
589
590
            scrollViewUpdater = nil

Zach Knox's avatar
Zach Knox committed
591
592
593
594
595
596
        default: break
        
        }
    }
    
    /// Method to update the modal view for a particular amount of translation
597
598
599
600
601
602
    /// by panning in the vertical direction.
    ///
    /// The translation of the modal view is proportional to the panning
    /// distance until the `elasticThreshold`, after which it increases at a
    /// slower rate, given by `elasticFactor`, to indicate that the
    /// `dismissThreshold` is nearing.
Zach Knox's avatar
Zach Knox committed
603
604
    ///
    /// Once the `dismissThreshold` is reached, the modal view controller is
605
    /// dismissed.
Zach Knox's avatar
Zach Knox committed
606
607
608
609
610
611
    ///
    /// - parameter translation: The translation of the user's pan gesture in
    ///   the container view in the vertical direction
    private func updatePresentedViewForTranslation(inVerticalDirection translation: CGFloat) {
        
        let elasticThreshold: CGFloat = 120
612
613
614
615
        let dismissThreshold: CGFloat = 240
        
        let translationFactor: CGFloat = 1/2
        
Zach Knox's avatar
Zach Knox committed
616
617
618
619
620
        /// Nothing happens if the pan gesture is performed from bottom
        /// to top i.e. if the translation is negative
        if translation >= 0 {
            let translationForModal: CGFloat = {
                if translation >= elasticThreshold {
621
622
                    let frictionLength = translation - elasticThreshold
                    let frictionTranslation = 30 * atan(frictionLength/120) + frictionLength/10
Zach Knox's avatar
Zach Knox committed
623
624
625
626
627
                    return frictionTranslation + (elasticThreshold * translationFactor)
                } else {
                    return translation * translationFactor
                }
            }()
628
            
Zach Knox's avatar
Zach Knox committed
629
630
631
632
633
634
635
636
637
638
639
640
641
642
            presentedView?.transform = CGAffineTransform(translationX: 0, y: translationForModal)
            
            if translation >= dismissThreshold {
                presentedViewController.dismiss(animated: true, completion: nil)
            }
        }
    }
    
    // MARK: - UIGestureRecognizerDelegate methods
    
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        guard gestureRecognizer.isEqual(pan) else {
            return false
        }
643
        
Zach Knox's avatar
Zach Knox committed
644
645
646
647
        return true
    }
    
}