// // 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 toolbarView: ToolbarView! private var gestureState: GestureState? private var trackingScroll = false private var prevScrollViewContentOffset: CGPoint? private var toolbarOffset: CGFloat = 0 { didSet { toolbarView.transform = CGAffineTransform(translationX: 0, y: toolbarOffset) } } 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) toolbarView = ToolbarView(navigator: navigator) 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 else { return } vc.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarView.bounds.height - view.safeAreaInsets.bottom - toolbarOffset, 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) } 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: 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 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 } 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 { UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) { self.toolbarOffset = 0 } completion: { (_) in self.setBrowserVCSafeAreaInsets(self.currentBrowserVC) } trackingScroll = false } else if delta > 0 || (delta < 0 && toolbarOffset < toolbarView.bounds.height) { toolbarOffset = max(0, min(toolbarView.bounds.height, toolbarOffset + delta)) setBrowserVCSafeAreaInsets(currentBrowserVC) } } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { guard trackingScroll else { return } trackingScroll = false let finalOffset: CGFloat if velocity.y < 0 { finalOffset = 0 } else { finalOffset = toolbarView.bounds.height } UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) { self.toolbarOffset = finalOffset } } }