From 5e9c6e588022dc5cfb588cac39c68e54a55bae2c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 28 Oct 2020 17:57:34 -0700 Subject: [PATCH] Tab Bar: Adds tab bar view/view controller --- App/Browser View/BrowserView.swift | 34 ++- App/Browser View/BrowserViewController.swift | 29 +++ App/Tabs/TabBarView.swift | 232 ++++++++++++++++++ App/Tabs/TabBarViewController.swift | 74 ++++++ App/Tabs/TabController.swift | 11 +- SBrowser.xcodeproj/project.pbxproj | 14 +- .../xcshareddata/xcschemes/App.xcscheme | 3 +- 7 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 App/Tabs/TabBarView.swift create mode 100644 App/Tabs/TabBarViewController.swift diff --git a/App/Browser View/BrowserView.swift b/App/Browser View/BrowserView.swift index 6a5efb1..4cff180 100644 --- a/App/Browser View/BrowserView.swift +++ b/App/Browser View/BrowserView.swift @@ -21,6 +21,14 @@ class BrowserView: UIView didSet { addSubview(toolbarView!) } } + var tabBarView: TabBarView? { + didSet { addSubview(tabBarView!) } + } + + var tabBarViewVisible: Bool = true { + didSet { setNeedsLayout() } + } + var autocompleteView: UIView? { didSet { addSubview(autocompleteView!) @@ -129,6 +137,25 @@ class BrowserView: UIView } } + if let tabBarView = tabBarView { + bringSubviewToFront(tabBarView) + + if tabBarViewVisible { + tabBarView.isHidden = false + + let tabViewSize = tabBarView.sizeThatFits(bounds.size) + + tabBarView.frame = CGRect( + x: 0.0, y: webViewContentInset.top, + width: bounds.width, height: tabViewSize.height + ) + + webViewContentInset.top += tabBarView.frame.height + } else { + tabBarView.isHidden = true + } + } + // Fix web view content insets if let webView = webView { webView.scrollView.layer.masksToBounds = true @@ -154,10 +181,15 @@ class BrowserView: UIView autocompleteView.layer.shadowPath = shadowPath.cgPath if let toolbarView = toolbarView, let urlBar = toolbarView.urlBar { + var yOffset = toolbarView.frame.maxY + 3.0 + if let tabBarView = tabBarView, tabBarViewVisible { + yOffset += tabBarView.frame.height + } + let urlFrame = self.convert(urlBar.frame, from: urlBar.superview) autocompleteView.frame = CGRect( x: urlFrame.minX, - y: toolbarView.frame.maxY + 3.0, + y: yOffset, width: urlFrame.width, height: bounds.height / 2.5 ) diff --git a/App/Browser View/BrowserViewController.swift b/App/Browser View/BrowserViewController.swift index c9b471e..b806f4b 100644 --- a/App/Browser View/BrowserViewController.swift +++ b/App/Browser View/BrowserViewController.swift @@ -5,6 +5,7 @@ // Created by James Magahern on 7/21/20. // +import Combine import UIKit import UniformTypeIdentifiers @@ -18,6 +19,7 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat var webView: WKWebView { tab.webView } private let tabController = TabController() + private let tabBarViewController: TabBarViewController private let toolbarController = ToolbarViewController() private let findOnPageController = FindOnPageViewController() @@ -32,6 +34,7 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat private var loadingObservation: NSKeyValueObservation? private var backButtonObservation: NSKeyValueObservation? private var forwardButtonObservation: NSKeyValueObservation? + private var activeTabObservation: AnyCancellable? private var loadError: Error? @@ -39,10 +42,12 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat init() { self.tab = tabController.tabs.first! + self.tabBarViewController = TabBarViewController(tabController: tabController) super.init(nibName: nil, bundle: nil) addChild(toolbarController) addChild(findOnPageController) + addChild(tabBarViewController) didChangeTab(tab) } @@ -52,6 +57,7 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat override func loadView() { browserView.toolbarView = toolbarController.toolbarView browserView.findOnPageView = findOnPageController.findOnPageView + browserView.tabBarView = tabBarViewController.tabBarView // Refresh button toolbarController.urlBar.refreshButton.addAction(UIAction(handler: { [unowned self] action in @@ -215,6 +221,16 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat browserView.setFindOnPageVisible(false, animated: true) }), for: .touchUpInside) + // Tab controller + activeTabObservation = tabController.$activeTabIndex + .receive(on: RunLoop.main) + .sink(receiveValue: { [unowned self] (activeTab: Int) in + let tab = tabController.tabs[activeTab] + if self.tab != tab { + self.tab = tab + } + }) + self.view = browserView } @@ -236,9 +252,19 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat } else { toolbarController.urlBar.textField.text = "" } + + // Figure out which tab this corresponds to + let tab = tabController.tabs.first { $0.webView == webView } + if let tab = tab, let tabIndex = tabController.tabs.firstIndex(of: tab) { + tabBarViewController.tabBarView.reloadTab(atIndex: tabIndex) + } } private func didChangeTab(_ tab: Tab) { + if let activeIndex = tabController.tabs.firstIndex(of: tab) { + tabController.activeTabIndex = activeIndex + } + tab.delegate = self let webView = tab.webView @@ -253,6 +279,9 @@ class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegat // Autocomplete view browserView.autocompleteView = autocompleteViewController.view + // Show tab bar view? + browserView.tabBarViewVisible = tabController.tabs.count > 1 + // Load progress updateLoadProgress(forWebView: webView) loadingObservation = webView.observe(\.estimatedProgress) { [unowned self] (webView, observedChange) in diff --git a/App/Tabs/TabBarView.swift b/App/Tabs/TabBarView.swift new file mode 100644 index 0000000..7e648f6 --- /dev/null +++ b/App/Tabs/TabBarView.swift @@ -0,0 +1,232 @@ +// +// TabBarView.swift +// App +// +// Created by James Magahern on 10/28/20. +// + +import UIKit + +class TabView: UIControl +{ + var active: Bool = false { didSet { setNeedsLayout() } } + let label = UILabel(frame: .zero) + let closeButton = UIButton(frame: .zero) + let imageView = UIImageView(image: nil) + + private let leftSeparator = UIView(frame: .zero) + private let rightSeparator = UIView(frame: .zero) + + convenience init() { + self.init(frame: .zero) + + addSubview(label) + label.text = "Tab View" + label.font = .boldSystemFont(ofSize: 11.0) + + addSubview(closeButton) + closeButton.setImage(UIImage(systemName: "xmark.square.fill"), for: .normal) + closeButton.tintColor = .label + + addSubview(leftSeparator) + addSubview(rightSeparator) + leftSeparator.backgroundColor = .secondarySystemFill + rightSeparator.backgroundColor = .secondarySystemFill + + // Try just one for now + leftSeparator.isHidden = true + } + + override func layoutSubviews() { + super.layoutSubviews() + + let insetBounds = bounds.inset(by: layoutMargins) + + let closeButtonPadding = CGFloat(5.0) + let closeButtonSize = CGSize(width: insetBounds.height, height: insetBounds.height) + closeButton.frame = CGRect( + x: insetBounds.width - closeButtonSize.width, y: insetBounds.minY, + width: closeButtonSize.width, height: closeButtonSize.height + ) + + label.frame = CGRect( + x: insetBounds.minX, y: insetBounds.minY, + width: closeButton.frame.minX - closeButtonPadding, height: insetBounds.height + ) + + let separatorWidth = CGFloat(1.0) + leftSeparator.frame = CGRect( + x: 0.0, y: 0.0, + width: separatorWidth, height: bounds.height + ) + + rightSeparator.frame = CGRect( + x: bounds.width - separatorWidth, y: 0.0, + width: separatorWidth, height: bounds.height + ) + + if isTracking { + backgroundColor = .systemFill + } else if active { + backgroundColor = .init(dynamicProvider: { (traitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .light { + return .secondarySystemGroupedBackground + } else { + return .secondarySystemFill + } + }) + } else { + backgroundColor = .init(dynamicProvider: { (traitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .light { + return .secondarySystemFill + } else { + return .secondarySystemGroupedBackground + } + }) + } + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + super.beginTracking(touch, with: event) + setNeedsLayout() + return true + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + setNeedsLayout() + } +} + +protocol TabBarViewDataSource: class { + func numberOfTabs(forTabBarView: TabBarView) -> Int + func tabBarView(_ tabBarView: TabBarView, titleForTabAtIndex: Int) -> String + func tabBarView(_ tabBarView: TabBarView, imageForTabAtIndex: Int) -> UIImage? +} + +protocol TabBarViewDelegate: class { + func tabBarView(_ tabBarView: TabBarView, didClickToActivateTabAtIndex: Int) + func tabBarView(_ tabBarView: TabBarView, didClickToCloseTabAtIndex: Int) +} + +class TabBarView: UIView +{ + static let preferredHeight: CGFloat = 30.0 + + public var delegate: TabBarViewDelegate? + public var dataSource: TabBarViewDataSource? + + private var tabViews: [TabView] = [] + private var activeTabIndex: Int = 0 + private var tabContainerView = UIScrollView(frame: .zero) + + override func sizeThatFits(_ size: CGSize) -> CGSize { + CGSize(width: size.width, height: Self.preferredHeight) + } + + convenience init() { + self.init(frame: .zero) + + addSubview(tabContainerView) + backgroundColor = .secondarySystemGroupedBackground + tabContainerView.showsHorizontalScrollIndicator = false + tabContainerView.showsVerticalScrollIndicator = false + } + + public func reloadTabs() { + guard let dataSource = self.dataSource else { return } + + let numberOfTabs = dataSource.numberOfTabs(forTabBarView: self) + while numberOfTabs < tabViews.count { + let tabView = tabViews.removeLast() + tabView.removeFromSuperview() + } + + while numberOfTabs > tabViews.count { + tabViews.append(makeTabView()) + } + + for (i, _) in tabViews.enumerated() { + self.reloadTab(atIndex: i) + } + + setNeedsLayout() + } + + public func reloadTab(atIndex index: Int) { + guard let dataSource = self.dataSource else { return } + if index > tabViews.count - 1 { return } + + let title = dataSource.tabBarView(self, titleForTabAtIndex: index) + let image = dataSource.tabBarView(self, imageForTabAtIndex: index) + + let tabView = tabViews[index] + tabView.label.text = title + tabView.imageView.image = image + } + + public func activateTab(atIndex index: Int) { + self.activeTabIndex = index + setNeedsLayout() + } + + private func makeTabView() -> TabView { + let tabView = TabView() + tabView.addAction(UIAction(handler: { [unowned self, tabView] _ in + guard let delegate = self.delegate else { return } + guard let tabIndex = self.tabViews.firstIndex(of: tabView) else { return } + + delegate.tabBarView(self, didClickToActivateTabAtIndex: tabIndex) + }), for: .touchUpInside) + + tabView.closeButton.addAction(UIAction(handler: { [unowned self, tabView] _ in + guard let delegate = self.delegate else { return } + guard let tabIndex = self.tabViews.firstIndex(of: tabView) else { return } + + delegate.tabBarView(self, didClickToCloseTabAtIndex: tabIndex) + }), for: .touchUpInside) + + return tabView + } + + override func layoutSubviews() { + super.layoutSubviews() + + let tabContainerBounds = bounds + tabContainerView.frame = tabContainerBounds + + let minimumTabWidth = CGFloat(140.0) + let maximumTabWidth = tabContainerBounds.width + + var xOffset = CGFloat(0.0) + var tabWidth: CGFloat = (tabContainerBounds.width / CGFloat(tabViews.count)) + if tabWidth < minimumTabWidth { + tabWidth = minimumTabWidth + } else if tabWidth > maximumTabWidth { + tabWidth = maximumTabWidth + } + + for (i, tabView) in tabViews.enumerated() { + tabContainerView.addSubview(tabView) + + tabView.frame = CGRect( + x: xOffset, + y: tabContainerBounds.minY, + width: tabWidth, + height: tabContainerBounds.height + ) + + xOffset += tabView.frame.width + + if i == activeTabIndex { + tabView.active = true + } else { + tabView.active = false + } + } + + tabContainerView.contentSize = CGSize( + width: xOffset, height: tabContainerBounds.height + ) + } +} diff --git a/App/Tabs/TabBarViewController.swift b/App/Tabs/TabBarViewController.swift new file mode 100644 index 0000000..750b3bb --- /dev/null +++ b/App/Tabs/TabBarViewController.swift @@ -0,0 +1,74 @@ +// +// TabBarViewController.swift +// App +// +// Created by James Magahern on 10/28/20. +// + +import Combine +import UIKit + +class TabBarViewController: UIViewController, TabBarViewDataSource, TabBarViewDelegate +{ + let tabBarView = TabBarView() + + private var tabController: TabController + + private var tabObserver: AnyCancellable? + private var activeTabIndexObserver: AnyCancellable? + + override func loadView() { + self.view = tabBarView + } + + init(tabController: TabController) { + self.tabController = tabController + super.init(nibName: nil, bundle: nil) + + tabBarView.dataSource = self + tabBarView.delegate = self + tabBarView.reloadTabs() + + tabObserver = tabController.$tabs.sink(receiveValue: { [tabBarView] (newTabs: [Tab]) in + DispatchQueue.main.async { tabBarView.reloadTabs() } + }) + + activeTabIndexObserver = tabController.$activeTabIndex.sink(receiveValue: { [tabBarView] (activeIndex: Int) in + tabBarView.activateTab(atIndex: activeIndex) + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: TabBarViewDelegate + func tabBarView(_ tabBarView: TabBarView, didClickToActivateTabAtIndex index: Int) { + tabController.activeTabIndex = index + } + + func tabBarView(_ tabBarView: TabBarView, didClickToCloseTabAtIndex index: Int) { + let tab = tabController.tabs[index] + tabController.closeTab(tab) + } + + // MARK: TabBarViewDataSource + func numberOfTabs(forTabBarView: TabBarView) -> Int { + return tabController.tabs.count + } + + func tabBarView(_ tabBarView: TabBarView, titleForTabAtIndex index: Int) -> String { + let defaultTitle = "New Tab" + + if let title = tabController.tabs[index].title, title.count > 0 { + return title + } + + return defaultTitle + } + + func tabBarView(_ tabBarView: TabBarView, imageForTabAtIndex index: Int) -> UIImage? { + tabController.tabs[index].favicon + } + +} diff --git a/App/Tabs/TabController.swift b/App/Tabs/TabController.swift index 52dd225..f698340 100644 --- a/App/Tabs/TabController.swift +++ b/App/Tabs/TabController.swift @@ -9,7 +9,9 @@ import Foundation class TabController { - var tabs: [Tab] = [] + @Published var tabs: [Tab] = [] + @Published var activeTabIndex: Int = 0 + var policyManager = ResourcePolicyManager() init() { @@ -39,6 +41,13 @@ class TabController func closeTab(_ tab: Tab) { if let index = tabs.firstIndex(of: tab) { tabs.remove(at: index) + + if tabs.count > 0 { + activeTabIndex = tabs.count - 1 + } else { + _ = createNewTab(url: nil) + activeTabIndex = 0 + } } } } diff --git a/SBrowser.xcodeproj/project.pbxproj b/SBrowser.xcodeproj/project.pbxproj index 350a66a..6453ca6 100644 --- a/SBrowser.xcodeproj/project.pbxproj +++ b/SBrowser.xcodeproj/project.pbxproj @@ -40,6 +40,8 @@ 1ADFF4CB24CB8278006DC7AE /* ScriptControllerIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ADFF4CA24CB8278006DC7AE /* ScriptControllerIconView.swift */; }; 1ADFF4CD24CBB0C8006DC7AE /* ScriptPolicyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ADFF4CC24CBB0C8006DC7AE /* ScriptPolicyViewController.swift */; }; 1ADFF4D024CBBCD1006DC7AE /* ScriptPolicyControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ADFF4CF24CBBCD1006DC7AE /* ScriptPolicyControl.swift */; }; + CD01D5A5254A10BB00189CDC /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD01D5A4254A10BB00189CDC /* TabBarView.swift */; }; + CD01D5AB254A206D00189CDC /* TabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD01D5AA254A206D00189CDC /* TabBarViewController.swift */; }; CD7A8915251975B70075991E /* AutocompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7A8914251975B70075991E /* AutocompleteViewController.swift */; }; CD7A89172519872D0075991E /* KeyboardShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7A89162519872D0075991E /* KeyboardShortcuts.swift */; }; CD7A8919251989C90075991E /* UIKeyCommand+ConvInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7A8918251989C90075991E /* UIKeyCommand+ConvInit.swift */; }; @@ -118,6 +120,8 @@ 1ADFF4CA24CB8278006DC7AE /* ScriptControllerIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptControllerIconView.swift; sourceTree = ""; }; 1ADFF4CC24CBB0C8006DC7AE /* ScriptPolicyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptPolicyViewController.swift; sourceTree = ""; }; 1ADFF4CF24CBBCD1006DC7AE /* ScriptPolicyControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptPolicyControl.swift; sourceTree = ""; }; + CD01D5A4254A10BB00189CDC /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; + CD01D5AA254A206D00189CDC /* TabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarViewController.swift; sourceTree = ""; }; CD7A8914251975B70075991E /* AutocompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteViewController.swift; sourceTree = ""; }; CD7A89162519872D0075991E /* KeyboardShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcuts.swift; sourceTree = ""; }; CD7A8918251989C90075991E /* UIKeyCommand+ConvInit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKeyCommand+ConvInit.swift"; sourceTree = ""; }; @@ -170,6 +174,8 @@ isa = PBXGroup; children = ( 1A14FC2724D26749009B3F83 /* Tab.swift */, + CD01D5A4254A10BB00189CDC /* TabBarView.swift */, + CD01D5AA254A206D00189CDC /* TabBarViewController.swift */, 1AB88EFC24D3BA560006F850 /* TabController.swift */, 1AB88EFE24D3BBA50006F850 /* TabPickerViewController.swift */, ); @@ -464,6 +470,7 @@ 1ADFF4AE24C8ED32006DC7AE /* ResourcePolicyManager.swift in Sources */, 1ADFF47424C7DE9C006DC7AE /* BrowserViewController.swift in Sources */, CDCE2668251AAA9A007FE92A /* FontSizeAdjustView.swift in Sources */, + CD01D5A5254A10BB00189CDC /* TabBarView.swift in Sources */, 1ADFF4D024CBBCD1006DC7AE /* ScriptPolicyControl.swift in Sources */, 1A03810D24E71CA700826501 /* ToolbarView.swift in Sources */, CD853BD424E77BF900D2BDCC /* HistoryItem.swift in Sources */, @@ -481,6 +488,7 @@ CD7A89172519872D0075991E /* KeyboardShortcuts.swift in Sources */, 1ADFF4CD24CBB0C8006DC7AE /* ScriptPolicyViewController.swift in Sources */, 1A14FC2824D26749009B3F83 /* Tab.swift in Sources */, + CD01D5AB254A206D00189CDC /* TabBarViewController.swift in Sources */, 1ADFF47924C7DFF8006DC7AE /* BrowserView.swift in Sources */, CDCE2664251AA80F007FE92A /* DocumentControlViewController.swift in Sources */, 1AB88EFF24D3BBA50006F850 /* TabPickerViewController.swift in Sources */, @@ -647,7 +655,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = DQQH5H6GBD; INFOPLIST_FILE = "App/Supporting Files/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -658,7 +666,6 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.rosslerattix; PRODUCT_NAME = "rossler\\\\attix"; - PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "App/Supporting Files/SBrowser-Bridging-Header.h"; @@ -678,7 +685,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = DQQH5H6GBD; INFOPLIST_FILE = "App/Supporting Files/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -689,7 +696,6 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.rosslerattix; PRODUCT_NAME = "rossler\\\\attix"; - PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "App/Supporting Files/SBrowser-Bridging-Header.h"; diff --git a/SBrowser.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/SBrowser.xcodeproj/xcshareddata/xcschemes/App.xcscheme index 90a6659..6bec367 100644 --- a/SBrowser.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/SBrowser.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -40,7 +40,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES" - internalIOSLaunchStyle = "2"> + internalIOSLaunchStyle = "2" + viewDebuggingEnabled = "No">