Dactyloidae/mobile/ios/Client/Frontend/Browser/TabScrollController.swift
2026-06-26 21:04:09 -07:00

344 lines
12 KiB
Swift

/* 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
}
}
// Constraint-based animation is causing PDF docs to flicker. This is used to bypass this animation.
var isTabShowingPDF: Bool {
return (tab?.mimeType ?? "") == MimeType.PDF.rawValue
}
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 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 footer?.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
}
func isBouncingAtBottom() -> Bool {
guard let scrollView = scrollView else { return false }
return scrollView.contentOffset.y > (scrollView.contentSize.height - scrollView.frame.size.height) && scrollView.contentSize.height > scrollView.frame.size.height
}
@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() {
let bottomIsNotRubberbanding = contentOffset.y + scrollViewHeight < contentSize.height
let topIsRubberbanding = contentOffset.y <= 0
if isTabShowingPDF || ((toolbarState != .collapsed || topIsRubberbanding) && bottomIsNotRubberbanding) {
scrollWithDelta(delta)
}
if headerTopOffset == -topScrollHeight && footerBottomOffset == bottomScrollHeight {
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)?) {
guard let scrollView = scrollView else { return }
let initialContentOffset = scrollView.contentOffset
// If this function is used to fully animate the toolbar from hidden to shown, keep the page from scrolling by adjusting contentOffset,
// Otherwise when the toolbar is hidden and a link navigated, showing the toolbar will scroll the page and
// produce a ~50px page jumping effect in response to tap navigations.
let isShownFromHidden = headerTopOffset == -topScrollHeight && headerOffset == 0
let animation: () -> Void = {
if isShownFromHidden {
scrollView.contentOffset = CGPoint(x: initialContentOffset.x, y: initialContentOffset.y + self.topScrollHeight)
}
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 scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if tabIsLoading() || isBouncingAtBottom() {
return
}
if (decelerate || (toolbarState == .animating && !decelerate)) && checkScrollHeightIsLargeEnoughForScrolling() {
if scrollDirection == .up {
showToolbars(animated: !isTabShowingPDF)
} else if scrollDirection == .down {
hideToolbars(animated: !isTabShowingPDF)
}
}
}
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
}
}