1 //  Copyright (c) 2018, Applidium. All rights reserved
2 //  ScrollViewOverlayTranslationDriver.swift
3 //  OverlayContainer
4 //
5 //  Created by Gaétan Zanella on 29/11/2018.
6 //
7 
8 import UIKit
9 
10 class ScrollViewOverlayTranslationDriver: OverlayTranslationDriver, OverlayScrollViewDelegate {
11 
12     weak var translationController: OverlayTranslationController?
13     weak var scrollView: UIScrollView?
14 
15     private let scrollViewDelegateProxy = OverlayScrollViewDelegateProxy()
16 
17     // (gz) 2018-11-27 The overlay transaction is not always equal to the scroll view translation.
18     // The user can scroll bottom then drag the overlay up repeatedly in a single gesture.
19     private var overlayTranslation: CGFloat = 0
20     private var scrollViewTranslation: CGFloat = 0
21     private var lastContentOffsetWhileScrolling: CGPoint = .zero
22 
23     // MARK: - Life Cycle
24 
25     init(translationController: OverlayTranslationController, scrollView: UIScrollView) {
26         self.translationController = translationController
27         self.scrollView = scrollView
28         scrollViewDelegateProxy.forward(to: self, delegateInvocationsFrom: scrollView)
29         lastContentOffsetWhileScrolling = scrollView.contentOffset
30     }
31 
32     // MARK: - OverlayTranslationDriver
33 
cleannull34     func clean() {
35         scrollViewDelegateProxy.cancelForwarding()
36     }
37 
38     // MARK: - OverlayScrollViewDelegate
39 
overlayScrollViewWillBeginDraggingnull40     func overlayScrollViewWillBeginDragging(_ scrollView: UIScrollView) {
41         translationController?.startOverlayTranslation()
42     }
43 
overlayScrollViewDidScrollnull44     func overlayScrollViewDidScroll(_ scrollView: UIScrollView) {
45         guard let controller = translationController else { return }
46         let previousTranslation = scrollViewTranslation
47         scrollViewTranslation = scrollView.panGestureRecognizer.translation(in: scrollView).y
48         if shouldDragOverlay(following: scrollView) {
49             if scrollView.isContentOriginInBounds {
50                 overlayTranslation += -scrollView.topOffsetInContent
51             } else {
52                 overlayTranslation += scrollViewTranslation - previousTranslation
53             }
54             let offset = adjustedContentOffset(dragging: scrollView)
55             lastContentOffsetWhileScrolling = offset
56             scrollView.contentOffset = offset // Warning : calls `overlayScrollViewDidScroll(_:)` again
57             controller.dragOverlay(withOffset: overlayTranslation, usesFunction: false)
58         } else {
59             lastContentOffsetWhileScrolling = scrollView.contentOffset
60         }
61     }
62 
63     func overlayScrollView(_ scrollView: UIScrollView,
64                            willEndDraggingwithVelocity velocity: CGPoint,
65                            targetContentOffset: UnsafeMutablePointer<CGPoint>) {
66         guard let controller = translationController else { return }
67         overlayTranslation = 0
68         scrollViewTranslation = 0
69         // (gz) 2018-11-27 We reset the translation each time the user ends dragging.
70         // Otherwise the calculation is wrong in `overlayScrollViewDidScroll(_:)`
71         // if the user drags the overlay while the animation did not finish.
72         scrollView.panGestureRecognizer.setTranslation(.zero, in: nil)
73         // (gz) 2018-01-24 We adjust the content offset and the velocity only if the overlay will be dragged.
74         switch controller.translationPosition {
75         case .bottom where targetContentOffset.pointee.y > -scrollView.oc_adjustedContentInset.top:
76             // (gz) 2018-11-26 The user raises its finger in the bottom position
77             // and the content offset will exceed the top content inset.
78             targetContentOffset.pointee.y = -scrollView.oc_adjustedContentInset.top
79         case .inFlight where !controller.overlayHasReachedANotch():
80             targetContentOffset.pointee.y = lastContentOffsetWhileScrolling.y
81         case .top, .bottom, .inFlight, .stationary:
82             break
83         }
84         // If the overlay is in flight and the user scrolls bottom, we ignore the velocity and we do not
85         // modify the target offset.
86         let adjustedVelocity: CGPoint
87         if shouldDragOverlay(following: scrollView) {
88             adjustedVelocity = velocity
89         } else {
90             adjustedVelocity = .zero
91         }
92         controller.endOverlayTranslation(withVelocity: adjustedVelocity)
93     }
94 
95     // MARK: - Private
96 
shouldDragOverlaynull97     private func shouldDragOverlay(following scrollView: UIScrollView) -> Bool {
98         guard let controller = translationController, scrollView.isTracking else { return false }
99         let velocity = scrollView.panGestureRecognizer.velocity(in: nil).y
100         let movesUp = velocity < 0
101         switch controller.translationPosition {
102         case .bottom:
103             return !scrollView.isContentOriginInBounds && scrollView.scrollsUp
104         case .top:
105             return scrollView.isContentOriginInBounds && !movesUp
106         case .inFlight:
107             return scrollView.isContentOriginInBounds || scrollView.scrollsUp
108         case .stationary:
109             return false
110         }
111     }
112 
adjustedContentOffsetnull113     private func adjustedContentOffset(dragging scrollView: UIScrollView) -> CGPoint {
114         guard let controller = translationController else { return .zero }
115         var contentOffset = lastContentOffsetWhileScrolling
116         let topInset = -scrollView.oc_adjustedContentInset.top
117         switch controller.translationPosition {
118         case .inFlight, .top:
119             // (gz) 2018-11-26 The user raised its finger in the top or in flight positions while scrolling bottom.
120             // If the scroll animation did not finish when the user translates the overlay,
121             // the content offset may have exceeded the top inset. We adjust it.
122             if contentOffset.y < topInset {
123                 contentOffset.y = topInset
124             }
125         case .bottom, .stationary:
126             break
127         }
128         // (gz) 2018-11-26 Between two `overlayScrollViewDidScroll:` calls,
129         // the scrollView exceeds the top contentInset. We adjust the target.
130         if (contentOffset.y - topInset) * (scrollView.contentOffset.y - topInset) < 0 {
131             contentOffset.y = topInset
132         }
133         return contentOffset
134     }
135 }
136