// // 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) { let vc = UIActivityViewController(activityItems: [navigator.currentURL], 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) { 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 } } }