/
TabScrollController.swift
331 lines (278 loc) · 11.6 KB
/
TabScrollController.swift
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
58
59
60
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
98
99
100
101
102
103
104
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import UIKit
import SnapKit
private let ToolbarBaseAnimationDuration: CGFloat = 0.2
class TabScrollingController: NSObject {
enum ScrollDirection {
case up
case down
}
enum ToolbarState {
case collapsed
case visible
case animating
}
weak var tab: Tab? {
willSet {
self.scrollView?.delegate = nil
self.scrollView?.removeGestureRecognizer(panGesture)
}
didSet {
self.scrollView?.addGestureRecognizer(panGesture)
scrollView?.delegate = self
}
}
weak var header: UIView?
weak var footer: UIView?
weak var urlBar: URLBarView?
weak var snackBars: UIView?
weak var webViewContainerToolbar: UIView?
var footerBottomConstraint: Constraint?
var headerTopConstraint: Constraint?
var toolbarsShowing: Bool { return headerTopOffset == 0 }
fileprivate var suppressToolbarHiding: Bool = false
fileprivate var isZoomedOut: Bool = false
fileprivate var lastZoomedScale: CGFloat = 0
fileprivate var isUserZoom: Bool = false
fileprivate var headerTopOffset: CGFloat = 0 {
didSet {
headerTopConstraint?.update(offset: headerTopOffset)
header?.superview?.setNeedsLayout()
}
}
fileprivate var footerBottomOffset: CGFloat = 0 {
didSet {
footerBottomConstraint?.update(offset: footerBottomOffset)
footer?.superview?.setNeedsLayout()
}
}
fileprivate lazy var panGesture: UIPanGestureRecognizer = {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(TabScrollingController.handlePan(_:)))
panGesture.maximumNumberOfTouches = 1
panGesture.delegate = self
return panGesture
}()
fileprivate var scrollView: UIScrollView? { return tab?.webView?.scrollView }
fileprivate var contentOffset: CGPoint { return scrollView?.contentOffset ?? CGPoint.zero }
fileprivate var contentSize: CGSize { return scrollView?.contentSize ?? CGSize.zero }
fileprivate var scrollViewHeight: CGFloat { return scrollView?.frame.height ?? 0 }
fileprivate var topScrollHeight: CGFloat { return header?.frame.height ?? 0 }
fileprivate var bottomScrollHeight: CGFloat { return urlBar?.frame.height ?? 0 }
fileprivate var snackBarsFrame: CGRect { return snackBars?.frame ?? CGRect.zero }
fileprivate var lastContentOffset: CGFloat = 0
fileprivate var scrollDirection: ScrollDirection = .down
fileprivate var toolbarState: ToolbarState = .visible
override init() {
super.init()
}
func showToolbars(animated: Bool, completion: ((_ finished: Bool) -> Void)? = nil) {
if toolbarState == .visible {
completion?(true)
return
}
toolbarState = .visible
let durationRatio = abs(headerTopOffset / topScrollHeight)
let actualDuration = TimeInterval(ToolbarBaseAnimationDuration * durationRatio)
self.animateToolbarsWithOffsets(
animated,
duration: actualDuration,
headerOffset: 0,
footerOffset: 0,
alpha: 1,
completion: completion)
}
func hideToolbars(animated: Bool, completion: ((_ finished: Bool) -> Void)? = nil) {
if toolbarState == .collapsed {
completion?(true)
return
}
toolbarState = .collapsed
let durationRatio = abs((topScrollHeight + headerTopOffset) / topScrollHeight)
let actualDuration = TimeInterval(ToolbarBaseAnimationDuration * durationRatio)
self.animateToolbarsWithOffsets(
animated,
duration: actualDuration,
headerOffset: -topScrollHeight,
footerOffset: bottomScrollHeight,
alpha: 0,
completion: completion)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
if !checkScrollHeightIsLargeEnoughForScrolling() && !toolbarsShowing {
showToolbars(animated: true, completion: nil)
}
}
}
func updateMinimumZoom() {
guard let scrollView = scrollView else {
return
}
self.isZoomedOut = roundNum(scrollView.zoomScale) == roundNum(scrollView.minimumZoomScale)
self.lastZoomedScale = self.isZoomedOut ? 0 : scrollView.zoomScale
}
func setMinimumZoom() {
guard let scrollView = scrollView else {
return
}
if self.isZoomedOut && roundNum(scrollView.zoomScale) != roundNum(scrollView.minimumZoomScale) {
scrollView.zoomScale = scrollView.minimumZoomScale
}
}
func resetZoomState() {
self.isZoomedOut = false
self.lastZoomedScale = 0
}
fileprivate func roundNum(_ num: CGFloat) -> CGFloat {
return round(100 * num) / 100
}
}
private extension TabScrollingController {
func tabIsLoading() -> Bool {
return tab?.loading ?? true
}
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
if tabIsLoading() {
return
}
if let containerView = scrollView?.superview {
let translation = gesture.translation(in: containerView)
let delta = lastContentOffset - translation.y
if delta > 0 {
scrollDirection = .down
} else if delta < 0 {
scrollDirection = .up
}
lastContentOffset = translation.y
if checkRubberbandingForDelta(delta) && checkScrollHeightIsLargeEnoughForScrolling() {
if (toolbarState != .collapsed || contentOffset.y <= 0) && contentOffset.y + scrollViewHeight < contentSize.height {
scrollWithDelta(delta)
}
if headerTopOffset == -topScrollHeight {
toolbarState = .collapsed
} else if headerTopOffset == 0 {
toolbarState = .visible
} else {
toolbarState = .animating
}
}
if gesture.state == .ended || gesture.state == .cancelled {
lastContentOffset = 0
}
showOrHideWebViewContainerToolbar()
}
}
func checkRubberbandingForDelta(_ delta: CGFloat) -> Bool {
return !((delta < 0 && contentOffset.y + scrollViewHeight > contentSize.height &&
scrollViewHeight < contentSize.height) ||
contentOffset.y < delta)
}
func scrollWithDelta(_ delta: CGFloat) {
if scrollViewHeight >= contentSize.height {
return
}
var updatedOffset = headerTopOffset - delta
headerTopOffset = clamp(updatedOffset, min: -topScrollHeight, max: 0)
if isHeaderDisplayedForGivenOffset(updatedOffset) {
scrollView?.contentOffset = CGPoint(x: contentOffset.x, y: contentOffset.y - delta)
}
updatedOffset = footerBottomOffset + delta
footerBottomOffset = clamp(updatedOffset, min: 0, max: bottomScrollHeight)
let alpha = 1 - abs(headerTopOffset / topScrollHeight)
urlBar?.updateAlphaForSubviews(alpha)
}
func isHeaderDisplayedForGivenOffset(_ offset: CGFloat) -> Bool {
return offset > -topScrollHeight && offset < 0
}
func clamp(_ y: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
if y >= max {
return max
} else if y <= min {
return min
}
return y
}
func animateToolbarsWithOffsets(_ animated: Bool, duration: TimeInterval, headerOffset: CGFloat, footerOffset: CGFloat, alpha: CGFloat, completion: ((_ finished: Bool) -> Void)?) {
let animation: () -> Void = {
self.headerTopOffset = headerOffset
self.footerBottomOffset = footerOffset
self.urlBar?.updateAlphaForSubviews(alpha)
self.header?.superview?.layoutIfNeeded()
}
if animated {
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction, animations: animation, completion: completion)
} else {
animation()
completion?(true)
}
}
func checkScrollHeightIsLargeEnoughForScrolling() -> Bool {
return (UIScreen.main.bounds.size.height + 2 * UIConstants.ToolbarHeight) < scrollView?.contentSize.height ?? 0
}
func showOrHideWebViewContainerToolbar() {
if contentOffset.y >= webViewContainerToolbar?.frame.height ?? 0 {
webViewContainerToolbar?.isHidden = true
} else {
webViewContainerToolbar?.isHidden = false
}
}
}
extension TabScrollingController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension TabScrollingController: UIScrollViewDelegate {
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
if targetContentOffset.pointee.y + scrollView.frame.size.height >= scrollView.contentSize.height {
suppressToolbarHiding = true
showToolbars(animated: true)
}
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if tabIsLoading() {
return
}
if (decelerate || (toolbarState == .animating && !decelerate)) && checkScrollHeightIsLargeEnoughForScrolling() {
if scrollDirection == .up {
showToolbars(animated: true)
} else if scrollDirection == .down && !suppressToolbarHiding {
hideToolbars(animated: true)
}
}
suppressToolbarHiding = false
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
// Only mess with the zoom level if the user did not initate the zoom via a zoom gesture
if self.isUserZoom {
return
}
//scrollViewDidZoom will be called multiple times when a rotation happens.
// In that case ALWAYS reset to the minimum zoom level if the previous state was zoomed out (isZoomedOut=true)
if isZoomedOut {
scrollView.zoomScale = scrollView.minimumZoomScale
} else if roundNum(scrollView.zoomScale) > roundNum(self.lastZoomedScale) && self.lastZoomedScale != 0 {
//When we have manually zoomed in we want to preserve that scale.
//But sometimes when we rotate a larger zoomScale is appled. In that case apply the lastZoomedScale
scrollView.zoomScale = self.lastZoomedScale
}
}
func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
self.isUserZoom = true
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
self.isUserZoom = false
showOrHideWebViewContainerToolbar()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
showOrHideWebViewContainerToolbar()
}
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
showToolbars(animated: true)
webViewContainerToolbar?.isHidden = false
return true
}
}