Gemini/Gemini-iOS/BrowserNavigationController...

352 lines
14 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()
}
}
}
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 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()
}
}
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()
}
}
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) {
let vc = UIActivityViewController(activityItems: [navigator.currentURL], applicationActivities: nil)
vc.popoverPresentationController?.sourceView = source
present(vc, animated: true)
}
private func showPreferences() {
let host = UIHostingController(rootView: PreferencesView())
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 {
return
}
let finalOffset: CGFloat = velocity.y < 0 ? 0 : 1
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) {
self.toolbarOffset = finalOffset
}
}
}