ScrollViewUpdater.swift 3.36 KB
Newer Older
Zach Knox's avatar
Zach Knox committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//
//  ScrollViewUpdater.swift
//  DeckTransition
//
//  Created by Harshil Shah on 06/12/17.
//  Copyright © 2017 Harshil Shah. All rights reserved.
//

import UIKit

/// This class is responsible for animating and managing state for a given
/// `UIScrollView`.
///
/// It has two linked jobs:
/// 1. Animate the card effect bounce when the scroll view is scrolled beyond
///    the top.
/// 2. Signal whether the scroll view can currently be dismissed, used by the
///    `DeckPresentationController` to help with handling the swipe-to-dismiss
///    pan gesture.
final class ScrollViewUpdater {
    
    // MARK: - Public variables
    
    var isDismissEnabled = false
    
    // MARK: - Private variables
    
    private weak var rootView: UIView?
    private weak var scrollView: UIScrollView?
    private var observation: NSKeyValueObservation?
    
    // MARK: - Initializers
    
    init(withRootView rootView: UIView, scrollView: UIScrollView) {
        self.rootView = rootView
        self.scrollView = scrollView
        self.observation = scrollView.observe(\.contentOffset, options: [.initial], changeHandler: { [weak self] _, _ in
            self?.scrollViewDidScroll()
        })
    }
    
    deinit {
        observation = nil
    }
    
    // MARK: - Private methods
    
    private func scrollViewDidScroll() {
        guard let rootView = rootView, let scrollView = scrollView else {
            return
        }
        
        /// Since iOS 11, the "top" position of a `UIScrollView` is not when
        /// its `contentOffset.y` is 0, but when `contentOffset.y` added to it's
        /// `safeAreaInsets.top` is 0, so that is adjusted for here.
        let offset: CGFloat = {
            if #available(iOS 11, *) {
58
                return scrollView.contentOffset.y + scrollView.contentInset.top + scrollView.safeAreaInsets.top
Zach Knox's avatar
Zach Knox committed
59
            } else {
60
                return scrollView.contentOffset.y + scrollView.contentInset.top
Zach Knox's avatar
Zach Knox committed
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
            }
        }()
        
        /// If the `scrollView` is not at the top, then do nothing.
        /// Additionally, dismissal is not allowed.
        ///
        /// If the `scrollView` is at the top or beyond, but is decelerating,
        /// this means that it reached to the top as the result of momentum from
        /// a swipe. In these cases, in order to retain the "card" effect, we
        /// move the `rootView` and the `scrollView`'s contents to make it
        /// appear as if the entire presented card is shifting down.
        ///
        /// Lastly, if the `scrollView` is at the top or beyond and isn't
        /// decelerating, then that means that the user is panning from top to
        /// bottom and has no more space to scroll within the `scrollView`.
        /// The pan gesture which controls the dismissal is allowed to take over
        /// now, and the scrollView's natural bounce is stopped.
        
        if offset > 0 {
            scrollView.bounces = true
            isDismissEnabled = false
        } else {
            if scrollView.isDecelerating {
                rootView.transform = CGAffineTransform(translationX: 0, y: -offset)
                scrollView.subviews.forEach {
                    $0.transform = CGAffineTransform(translationX: 0, y: offset)
                }
            } else {
                scrollView.bounces = false
                isDismissEnabled = true
            }
        }
    }
    
}