diff --git a/BrowserCore/NavigationManager.swift b/BrowserCore/NavigationManager.swift index 0ddc061..83a8a48 100644 --- a/BrowserCore/NavigationManager.swift +++ b/BrowserCore/NavigationManager.swift @@ -65,12 +65,13 @@ public class NavigationManager: NSObject, ObservableObject { navigationOperation.send(.go) } - public func reload() { + @objc public func reload() { let url = currentURL currentURL = url + // todo: send navigation op } - @objc public func back() { + @objc public func goBack() { back(count: 1) } @@ -85,7 +86,7 @@ public class NavigationManager: NSObject, ObservableObject { navigationOperation.send(.backward(count: count)) } - @objc public func forward() { + @objc public func goForward() { forward(count: 1) } diff --git a/Gemini-iOS/BrowserNavigationController.swift b/Gemini-iOS/BrowserNavigationController.swift index 439f48a..913dc0c 100644 --- a/Gemini-iOS/BrowserNavigationController.swift +++ b/Gemini-iOS/BrowserNavigationController.swift @@ -8,6 +8,7 @@ import UIKit import BrowserCore import Combine +import SwiftUI class BrowserNavigationController: UIViewController { @@ -17,7 +18,17 @@ class BrowserNavigationController: UIViewController { 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]() @@ -36,8 +47,24 @@ class BrowserNavigationController: UIViewController { view.backgroundColor = .systemBackground - currentBrowserVC = BrowserWebViewController(navigator: navigator, url: navigator.currentURL) - embedChild(currentBrowserVC) + 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) @@ -46,6 +73,23 @@ class BrowserNavigationController: UIViewController { 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 @@ -70,8 +114,10 @@ class BrowserNavigationController: UIViewController { } currentBrowserVC.removeViewAndController() + currentBrowserVC.scrollViewDelegate = nil currentBrowserVC = newVC - embedChild(newVC) + currentBrowserVC.scrollViewDelegate = self + embedChild(newVC, in: browserContainer) } private let startEdgeNavigationSwipeDistance: CGFloat = 75 @@ -88,7 +134,7 @@ class BrowserNavigationController: UIViewController { 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) + embedChild(older, in: browserContainer) older.view.layer.zPosition = -2 older.view.transform = CGAffineTransform(translationX: -1 * edgeNavigationParallaxFactor * view.bounds.width, y: 0) @@ -97,7 +143,7 @@ class BrowserNavigationController: UIViewController { dimmingView.backgroundColor = .black dimmingView.layer.zPosition = -1 dimmingView.alpha = edgeNavigationMaxDimmingAlpha - view.embedSubview(dimmingView) + browserContainer.embedSubview(dimmingView) let animator = UIViewPropertyAnimator(duration: totalEdgeNavigationTime, curve: .easeInOut) { dimmingView.alpha = 0 older.view.transform = .identity @@ -113,13 +159,13 @@ class BrowserNavigationController: UIViewController { self.currentBrowserVC.view.transform = .identity if position == .end { - self.navigator.back() + 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) + embedChild(newer, in: browserContainer) newer.view.transform = CGAffineTransform(translationX: view.bounds.width, y: 0) newer.view.layer.zPosition = 2 @@ -128,7 +174,7 @@ class BrowserNavigationController: UIViewController { dimmingView.backgroundColor = .black dimmingView.layer.zPosition = 1 dimmingView.alpha = 0 - view.embedSubview(dimmingView) + browserContainer.embedSubview(dimmingView) let animator = UIViewPropertyAnimator(duration: totalEdgeNavigationTime, curve: .easeInOut) { dimmingView.alpha = self.edgeNavigationMaxDimmingAlpha newer.view.transform = .identity @@ -144,7 +190,7 @@ class BrowserNavigationController: UIViewController { self.currentBrowserVC.view.transform = .identity if position == .end { - self.navigator.forward() + self.navigator.goForward() } } gestureState = .forwards(animator) @@ -182,6 +228,17 @@ class BrowserNavigationController: UIViewController { 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) + } } @@ -191,3 +248,49 @@ extension BrowserNavigationController { 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 + } + } +} diff --git a/Gemini-iOS/BrowserWebViewController.swift b/Gemini-iOS/BrowserWebViewController.swift index 4582107..c4bcfae 100644 --- a/Gemini-iOS/BrowserWebViewController.swift +++ b/Gemini-iOS/BrowserWebViewController.swift @@ -16,9 +16,17 @@ class BrowserWebViewController: UIViewController { let navigator: NavigationManager let url: URL + + weak var scrollViewDelegate: UIScrollViewDelegate? { + didSet { + if isViewLoaded { + webView.scrollView.delegate = scrollViewDelegate + } + } + } + private var task: GeminiDataTask? private let renderer = GeminiHTMLRenderer() - private var loaded = false private var errorStack: UIStackView! @@ -66,6 +74,8 @@ class BrowserWebViewController: UIViewController { webView.backgroundColor = .systemBackground webView.isOpaque = false webView.navigationDelegate = self + // it is safe to set the delegate of the web view's internal scroll view becuase WebKit takes care of forwarding between its internal delegate and our own + webView.scrollView.delegate = scrollViewDelegate webView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(webView) NSLayoutConstraint.activate([ diff --git a/Gemini-iOS/ToolBar.swift b/Gemini-iOS/ToolBar.swift index 43b503b..9ccb8f7 100644 --- a/Gemini-iOS/ToolBar.swift +++ b/Gemini-iOS/ToolBar.swift @@ -26,7 +26,7 @@ struct ToolBar: View { Group { Spacer() - Button(action: navigator.back) { + Button(action: navigator.goBack) { Image(systemName: "arrow.left") .font(.system(size: 24)) } @@ -45,7 +45,7 @@ struct ToolBar: View { Spacer() - Button(action: navigator.forward) { + Button(action: navigator.goForward) { Image(systemName: "arrow.right") .font(.system(size: 24)) } diff --git a/Gemini-iOS/ToolbarView.swift b/Gemini-iOS/ToolbarView.swift new file mode 100644 index 0000000..fb7ff65 --- /dev/null +++ b/Gemini-iOS/ToolbarView.swift @@ -0,0 +1,125 @@ +// +// ToolbarView.swift +// Gemini-iOS +// +// Created by Shadowfacts on 12/19/20. +// + +import UIKit +import BrowserCore +import Combine + +class ToolbarView: UIView { + + let navigator: NavigationManager + + var showShareSheet: ((UIView) -> Void)? + var showPreferences: (() -> Void)? + + private var border: UIView! + private var backButton: UIButton! + private var forwardsButton: UIButton! + private var reloadButton: UIButton! + private var shareButton: UIButton! + private var prefsButton: UIButton! + + private var cancellables = [AnyCancellable]() + + init(navigator: NavigationManager) { + self.navigator = navigator + + super.init(frame: .zero) + + backgroundColor = .systemBackground + + border = UIView() + border.translatesAutoresizingMaskIntoConstraints = false + border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1) + addSubview(border) + NSLayoutConstraint.activate([ + border.leadingAnchor.constraint(equalTo: leadingAnchor), + border.trailingAnchor.constraint(equalTo: trailingAnchor), + border.topAnchor.constraint(equalTo: topAnchor), + border.heightAnchor.constraint(equalToConstant: 1), + ]) + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 24) + + backButton = UIButton() + backButton.addTarget(navigator, action: #selector(NavigationManager.goBack), for: .touchUpInside) + backButton.isEnabled = navigator.backStack.count > 0 + backButton.setImage(UIImage(systemName: "arrow.left", withConfiguration: symbolConfig), for: .normal) + backButton.accessibilityLabel = "Back" + backButton.isPointerInteractionEnabled = true + + forwardsButton = UIButton() + forwardsButton.addTarget(navigator, action: #selector(NavigationManager.goForward), for: .touchUpInside) + forwardsButton.isEnabled = navigator.forwardStack.count > 0 + forwardsButton.setImage(UIImage(systemName: "arrow.right", withConfiguration: symbolConfig), for: .normal) + forwardsButton.accessibilityLabel = "Forward" + forwardsButton.isPointerInteractionEnabled = true + + reloadButton = UIButton() + reloadButton.addTarget(navigator, action: #selector(NavigationManager.reload), for: .touchUpInside) + reloadButton.setImage(UIImage(systemName: "arrow.clockwise", withConfiguration: symbolConfig), for: .normal) + reloadButton.accessibilityLabel = "Reload" + reloadButton.isPointerInteractionEnabled = true + + shareButton = UIButton() + shareButton.addTarget(self, action: #selector(sharePressed), for: .touchUpInside) + shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: symbolConfig), for: .normal) + shareButton.accessibilityLabel = "Share" + shareButton.isPointerInteractionEnabled = true + + prefsButton = UIButton() + prefsButton.addTarget(self, action: #selector(prefsPressed), for: .touchUpInside) + prefsButton.setImage(UIImage(systemName: "gear", withConfiguration: symbolConfig), for: .normal) + prefsButton.accessibilityLabel = "Preferences" + prefsButton.isPointerInteractionEnabled = true + + let stack = UIStackView(arrangedSubviews: [ + backButton, + forwardsButton, + reloadButton, + shareButton, + prefsButton, + ]) + stack.axis = .horizontal + stack.distribution = .fillEqually + stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 5), + stack.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + ]) + + navigator.$currentURL + .sink { (_) in + self.backButton.isEnabled = navigator.backStack.count > 0 + self.forwardsButton.isEnabled = navigator.forwardStack.count > 0 + } + .store(in: &cancellables) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1) + } + + @objc private func sharePressed() { + showShareSheet?(shareButton) + } + + @objc private func prefsPressed() { + showPreferences?() + } + +} diff --git a/Gemini.xcodeproj/project.pbxproj b/Gemini.xcodeproj/project.pbxproj index 10d5d1f..b379242 100644 --- a/Gemini.xcodeproj/project.pbxproj +++ b/Gemini.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ D691A6A0252242FC00348C4B /* ToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A69F252242FC00348C4B /* ToolBar.swift */; }; D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */; }; D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; }; + D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AB2258E8E13008652BC /* ToolbarView.swift */; }; D6DA5783252396030048B65A /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DA5782252396030048B65A /* View+Extensions.swift */; }; D6E1529824BFAAA400FDF9D3 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */; }; D6E1529924BFAAA400FDF9D3 /* BrowserWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */; }; @@ -321,6 +322,7 @@ D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiDataTask.swift; sourceTree = ""; }; D69F00AD24BEA29100E37622 /* GeminiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiResponse.swift; sourceTree = ""; }; D69F00AF24BEA84D00E37622 /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = ""; }; + D6BC9AB2258E8E13008652BC /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; D6DA5782252396030048B65A /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = ""; }; D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowserWindowController.xib; sourceTree = ""; }; @@ -585,6 +587,7 @@ D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */, D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */, D688F662258C2479003A0A73 /* UIViewController+Children.swift */, + D6BC9AB2258E8E13008652BC /* ToolbarView.swift */, D691A6762522382E00348C4B /* BrowserViewController.swift */, D6E152A824BFFDF500FDF9D3 /* ContentView.swift */, D691A68625223A4600348C4B /* NavigationBar.swift */, @@ -1109,6 +1112,7 @@ D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */, D691A6A0252242FC00348C4B /* ToolBar.swift in Sources */, D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */, + D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */, D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */, D62BCEE2252553620031D894 /* ActivityView.swift in Sources */, D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */, diff --git a/Gemini/BrowserWindowController.swift b/Gemini/BrowserWindowController.swift index e6f9198..2b89605 100644 --- a/Gemini/BrowserWindowController.swift +++ b/Gemini/BrowserWindowController.swift @@ -109,7 +109,7 @@ extension BrowserWindowController: NSToolbarDelegate { item.paletteLabel = "Go Back" item.toolTip = "Go to the previous page" item.target = self - item.action = #selector(back) + item.action = #selector(goBack) item.isBordered = true if #available(macOS 10.16, *) { item.isNavigational = true @@ -128,7 +128,7 @@ extension BrowserWindowController: NSToolbarDelegate { item.paletteLabel = "Go Forward" item.toolTip = "Go to the next page" item.target = self - item.action = #selector(forward) + item.action = #selector(goForward) item.isBordered = true if #available(macOS 10.16, *) { item.isNavigational = true @@ -137,11 +137,11 @@ extension BrowserWindowController: NSToolbarDelegate { } @objc private func back() { - navigator.back() + navigator.goBack() } @objc private func forward() { - navigator.forward() + navigator.goForward() } }