mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-08-20 19:33:08 +09:00
344 lines
12 KiB
Swift
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
|
|
}
|
|
}
|