369 lines
15 KiB
Swift
369 lines
15 KiB
Swift
//
|
|
// BrowserNavigationController.swift
|
|
// Gemini-iOS
|
|
//
|
|
// Created by Shadowfacts on 12/17/20.
|
|
//
|
|
|
|
import UIKit
|
|
import BrowserCore
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
class BrowserNavigationController: UIViewController {
|
|
|
|
let navigator: NavigationManager
|
|
|
|
private var backBrowserVCs = [BrowserWebViewController]()
|
|
private var forwardBrowserVCs = [BrowserWebViewController]()
|
|
private var currentBrowserVC: BrowserWebViewController!
|
|
|
|
private var browserContainer: UIView!
|
|
private var navBarView: NavigationBarView!
|
|
private var toolbarView: ToolbarView!
|
|
|
|
private var gestureState: GestureState?
|
|
private var trackingScroll = false
|
|
private var scrollStartedBelowEnd = false
|
|
private var prevScrollViewContentOffset: CGPoint?
|
|
private var toolbarOffset: CGFloat = 0 {
|
|
didSet {
|
|
let realOffset = toolbarOffset * max(toolbarView.bounds.height, navBarView.bounds.height)
|
|
toolbarView.transform = CGAffineTransform(translationX: 0, y: realOffset)
|
|
navBarView.transform = CGAffineTransform(translationX: 0, y: -realOffset)
|
|
|
|
if (oldValue <= 0.5 && toolbarOffset > 0.5) || (oldValue > 0.5 && toolbarOffset <= 0.5) {
|
|
setNeedsStatusBarAppearanceUpdate()
|
|
|
|
if navBarView.textField.isFirstResponder {
|
|
navBarView.textField.resignFirstResponder()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override var prefersStatusBarHidden: Bool {
|
|
toolbarOffset > 0.5
|
|
}
|
|
|
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
|
.slide
|
|
}
|
|
|
|
private var cancellables = [AnyCancellable]()
|
|
|
|
init(navigator: NavigationManager) {
|
|
self.navigator = navigator
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .systemBackground
|
|
|
|
browserContainer = UIView()
|
|
browserContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
view.embedSubview(browserContainer)
|
|
|
|
currentBrowserVC = createBrowserVC(url: navigator.currentURL)
|
|
currentBrowserVC.scrollViewDelegate = self
|
|
embedChild(currentBrowserVC, in: browserContainer)
|
|
|
|
navBarView = NavigationBarView(navigator: navigator)
|
|
navBarView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(navBarView)
|
|
NSLayoutConstraint.activate([
|
|
navBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
navBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
navBarView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
])
|
|
|
|
toolbarView = ToolbarView(navigator: navigator)
|
|
toolbarView.showTableOfContents = self.showTableOfContents
|
|
toolbarView.showShareSheet = self.showShareSheet
|
|
toolbarView.showPreferences = self.showPreferences
|
|
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(toolbarView)
|
|
NSLayoutConstraint.activate([
|
|
toolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
toolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
toolbarView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
navigator.navigationOperation
|
|
.sink(receiveValue: self.onNavigate)
|
|
.store(in: &cancellables)
|
|
|
|
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized)))
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
|
|
setBrowserVCSafeAreaInsets(currentBrowserVC)
|
|
}
|
|
|
|
private func createBrowserVC(url: URL) -> BrowserWebViewController {
|
|
let vc = BrowserWebViewController(navigator: navigator, url: url)
|
|
setBrowserVCSafeAreaInsets(vc)
|
|
return vc
|
|
}
|
|
|
|
private func setBrowserVCSafeAreaInsets(_ vc: BrowserWebViewController) {
|
|
guard let toolbarView = toolbarView,
|
|
let navBarView = navBarView else { return }
|
|
vc.additionalSafeAreaInsets = UIEdgeInsets(
|
|
top: navBarView.bounds.height - view.safeAreaInsets.top,
|
|
left: 0,
|
|
bottom: toolbarView.bounds.height - view.safeAreaInsets.bottom,
|
|
right: 0
|
|
)
|
|
}
|
|
|
|
private func onNavigate(_ operation: NavigationManager.Operation) {
|
|
let newVC: BrowserWebViewController
|
|
|
|
switch operation {
|
|
case .go:
|
|
backBrowserVCs.append(currentBrowserVC)
|
|
newVC = BrowserWebViewController(navigator: navigator, url: navigator.currentURL)
|
|
|
|
case .reload:
|
|
currentBrowserVC.reload()
|
|
return
|
|
|
|
case let .backward(count: count):
|
|
var removed = backBrowserVCs.suffix(count)
|
|
backBrowserVCs.removeLast(count)
|
|
forwardBrowserVCs.insert(currentBrowserVC, at: 0)
|
|
newVC = removed.removeFirst()
|
|
forwardBrowserVCs.insert(contentsOf: removed, at: 0)
|
|
|
|
case let .forward(count: count):
|
|
var removed = forwardBrowserVCs.prefix(count)
|
|
forwardBrowserVCs.removeFirst(count)
|
|
backBrowserVCs.append(currentBrowserVC)
|
|
newVC = removed.removeFirst()
|
|
backBrowserVCs.append(contentsOf: removed)
|
|
}
|
|
|
|
currentBrowserVC.removeViewAndController()
|
|
currentBrowserVC.scrollViewDelegate = nil
|
|
currentBrowserVC = newVC
|
|
currentBrowserVC.scrollViewDelegate = self
|
|
embedChild(newVC, in: browserContainer)
|
|
|
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
|
self.toolbarOffset = 0
|
|
}
|
|
|
|
}
|
|
|
|
private let startEdgeNavigationSwipeDistance: CGFloat = 75
|
|
private let finishEdgeNavigationVelocityThreshold: CGFloat = 500
|
|
private let edgeNavigationMaxDimmingAlpha: CGFloat = 0.35
|
|
private let edgeNavigationParallaxFactor: CGFloat = 0.25
|
|
private let totalEdgeNavigationTime: TimeInterval = 0.4
|
|
|
|
@objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
let location = recognizer.location(in: view)
|
|
let velocity = recognizer.velocity(in: view)
|
|
|
|
switch recognizer.state {
|
|
case .began:
|
|
// swipe gestures cannot begin in navbar/toolbar bounds
|
|
let min = view.convert(navBarView.bounds, from: navBarView).maxY
|
|
let max = view.convert(toolbarView.bounds, from: toolbarView).minY
|
|
if toolbarOffset == 0 && (location.y < min || location.y > max) {
|
|
return
|
|
}
|
|
|
|
if location.x < startEdgeNavigationSwipeDistance && velocity.x > 0 && navigator.backStack.count > 0 {
|
|
let older = backBrowserVCs.last ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.last!)
|
|
embedChild(older, in: browserContainer)
|
|
older.view.layer.zPosition = -2
|
|
older.view.transform = CGAffineTransform(translationX: -1 * edgeNavigationParallaxFactor * view.bounds.width, y: 0)
|
|
|
|
let dimmingView = UIView()
|
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
|
dimmingView.backgroundColor = .black
|
|
dimmingView.layer.zPosition = -1
|
|
dimmingView.alpha = edgeNavigationMaxDimmingAlpha
|
|
browserContainer.embedSubview(dimmingView)
|
|
let animator = UIViewPropertyAnimator(duration: totalEdgeNavigationTime, curve: .easeInOut) {
|
|
dimmingView.alpha = 0
|
|
older.view.transform = .identity
|
|
self.currentBrowserVC.view.transform = CGAffineTransform(translationX: self.view.bounds.width, y: 0)
|
|
}
|
|
animator.addCompletion { (position) in
|
|
dimmingView.removeFromSuperview()
|
|
|
|
older.view.transform = .identity
|
|
older.view.layer.zPosition = 0
|
|
older.removeViewAndController()
|
|
|
|
self.currentBrowserVC.view.transform = .identity
|
|
|
|
if position == .end {
|
|
self.navigator.goBack()
|
|
}
|
|
|
|
if self.navBarView.textField.isFirstResponder {
|
|
self.navBarView.textField.resignFirstResponder()
|
|
}
|
|
}
|
|
gestureState = .backwards(animator)
|
|
} else if location.x > view.bounds.width - startEdgeNavigationSwipeDistance && velocity.x < 0 && navigator.forwardStack.count > 0 {
|
|
let newer = forwardBrowserVCs.first ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.first!)
|
|
embedChild(newer, in: browserContainer)
|
|
newer.view.transform = CGAffineTransform(translationX: view.bounds.width, y: 0)
|
|
newer.view.layer.zPosition = 2
|
|
|
|
let dimmingView = UIView()
|
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
|
dimmingView.backgroundColor = .black
|
|
dimmingView.layer.zPosition = 1
|
|
dimmingView.alpha = 0
|
|
browserContainer.embedSubview(dimmingView)
|
|
let animator = UIViewPropertyAnimator(duration: totalEdgeNavigationTime, curve: .easeInOut) {
|
|
dimmingView.alpha = self.edgeNavigationMaxDimmingAlpha
|
|
newer.view.transform = .identity
|
|
self.currentBrowserVC.view.transform = CGAffineTransform(translationX: -1 * self.edgeNavigationParallaxFactor * self.view.bounds.width, y: 0)
|
|
}
|
|
animator.addCompletion { (position) in
|
|
dimmingView.removeFromSuperview()
|
|
|
|
newer.removeViewAndController()
|
|
newer.view.layer.zPosition = 0
|
|
newer.view.transform = .identity
|
|
|
|
self.currentBrowserVC.view.transform = .identity
|
|
|
|
if position == .end {
|
|
self.navigator.goForward()
|
|
}
|
|
|
|
if self.navBarView.textField.isFirstResponder {
|
|
self.navBarView.textField.resignFirstResponder()
|
|
}
|
|
}
|
|
gestureState = .forwards(animator)
|
|
}
|
|
|
|
case .changed:
|
|
let translation = recognizer.translation(in: view)
|
|
|
|
switch gestureState {
|
|
case let .backwards(animator):
|
|
animator.fractionComplete = translation.x / view.bounds.width
|
|
case let .forwards(animator):
|
|
animator.fractionComplete = abs(translation.x) / view.bounds.width
|
|
case nil:
|
|
break
|
|
}
|
|
|
|
case .ended, .cancelled:
|
|
switch gestureState {
|
|
case let .backwards(animator):
|
|
let shouldComplete = location.x > view.bounds.width / 2 || velocity.x > finishEdgeNavigationVelocityThreshold
|
|
animator.isReversed = !shouldComplete
|
|
animator.startAnimation()
|
|
case let .forwards(animator):
|
|
let shouldComplete = location.x < view.bounds.width / 2 || velocity.x < -finishEdgeNavigationVelocityThreshold
|
|
animator.isReversed = !shouldComplete
|
|
animator.startAnimation()
|
|
case nil:
|
|
break
|
|
}
|
|
|
|
gestureState = nil
|
|
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
private func showTableOfContents() {
|
|
guard let doc = currentBrowserVC.document else { return }
|
|
let view = TableOfContentsView(document: doc) { (lineIndexToJumpTo) in
|
|
self.dismiss(animated: true) {
|
|
if let index = lineIndexToJumpTo {
|
|
self.currentBrowserVC.scrollToLine(index: index, animated: !UIAccessibility.isReduceMotionEnabled)
|
|
}
|
|
}
|
|
}
|
|
let host = UIHostingController(rootView: view)
|
|
present(host, animated: true)
|
|
}
|
|
|
|
private func showShareSheet(_ source: UIView) {
|
|
guard let doc = currentBrowserVC.document else { return }
|
|
let vc = UIActivityViewController(activityItems: [ActivityItemSource(document: doc)], applicationActivities: nil)
|
|
vc.popoverPresentationController?.sourceView = source
|
|
present(vc, animated: true)
|
|
}
|
|
|
|
private func showPreferences() {
|
|
let host = UIHostingController(rootView: PreferencesView(dismiss: { self.dismiss(animated: true) }))
|
|
present(host, animated: true)
|
|
}
|
|
|
|
}
|
|
|
|
extension BrowserNavigationController {
|
|
enum GestureState {
|
|
case backwards(UIViewPropertyAnimator)
|
|
case forwards(UIViewPropertyAnimator)
|
|
}
|
|
}
|
|
|
|
extension BrowserNavigationController: UIScrollViewDelegate {
|
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
trackingScroll = true
|
|
prevScrollViewContentOffset = scrollView.contentOffset
|
|
scrollStartedBelowEnd = scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom)
|
|
}
|
|
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
guard trackingScroll else { return }
|
|
defer { prevScrollViewContentOffset = scrollView.contentOffset }
|
|
guard let prevOffset = prevScrollViewContentOffset else { return }
|
|
|
|
let delta = scrollView.contentOffset.y - prevOffset.y
|
|
|
|
let belowEnd = scrollView.contentOffset.y > (scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom)
|
|
|
|
if belowEnd {
|
|
if scrollStartedBelowEnd {
|
|
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) {
|
|
self.toolbarOffset = 0
|
|
}
|
|
trackingScroll = false
|
|
}
|
|
} else if delta > 0 || (delta < 0 && toolbarOffset < 1) {
|
|
let normalizedDelta = delta / max(toolbarView.bounds.height, navBarView.bounds.height)
|
|
toolbarOffset = max(0, min(1, toolbarOffset + normalizedDelta))
|
|
}
|
|
}
|
|
|
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
|
guard trackingScroll else { return }
|
|
trackingScroll = false
|
|
|
|
if velocity.y == 0 && (toolbarOffset == 0 || toolbarOffset == 1) {
|
|
return
|
|
}
|
|
|
|
let finalOffset: CGFloat = velocity.y < 0 ? 0 : 1
|
|
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) {
|
|
self.toolbarOffset = finalOffset
|
|
}
|
|
}
|
|
}
|