/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import Foundation import Shared import WebKit struct TopTabsUX { static let TopTabsViewHeight: CGFloat = 44 static let TopTabsBackgroundColor = UIColor(rgb: 0x2a2a2e) static let TopTabsBackgroundPadding: CGFloat = 35 static let TopTabsBackgroundShadowWidth: CGFloat = 12 static let TabWidth: CGFloat = 190 static let CollectionViewPadding: CGFloat = 15 static let FaderPading: CGFloat = 8 static let SeparatorWidth: CGFloat = 1 static let HighlightLineWidth: CGFloat = 3 static let TabNudge: CGFloat = 1 // Nudge the favicon and close button by 1px static let TabTitleWidth: CGFloat = 110 static let TabTitlePadding: CGFloat = 10 static let AnimationSpeed: TimeInterval = 0.1 static let SeparatorYOffset: CGFloat = 7 static let SeparatorHeight: CGFloat = 32 } protocol TopTabsDelegate: class { func topTabsDidPressTabs() func topTabsDidPressNewTab(_ isPrivate: Bool) func topTabsDidTogglePrivateMode() func topTabsDidChangeTab() } protocol TopTabCellDelegate: class { func tabCellDidClose(_ cell: TopTabCell) } class TopTabsViewController: UIViewController { let tabManager: TabManager weak var delegate: TopTabsDelegate? fileprivate var isPrivate = false let faviconNotification = NSNotification.Name(rawValue: FaviconManager.FaviconDidLoad) lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: TopTabsViewLayout()) collectionView.register(TopTabCell.self, forCellWithReuseIdentifier: TopTabCell.Identifier) collectionView.showsVerticalScrollIndicator = false collectionView.showsHorizontalScrollIndicator = false collectionView.bounces = false collectionView.clipsToBounds = false collectionView.accessibilityIdentifier = "Top Tabs View" return collectionView }() fileprivate lazy var tabsButton: TabsButton = { let tabsButton = TabsButton.tabTrayButton() tabsButton.addTarget(self, action: #selector(TopTabsViewController.tabsTrayTapped), for: UIControlEvents.touchUpInside) tabsButton.accessibilityIdentifier = "TopTabsViewController.tabsButton" return tabsButton }() fileprivate lazy var newTab: UIButton = { let newTab = UIButton.newTabButton() newTab.addTarget(self, action: #selector(TopTabsViewController.newTabTapped), for: UIControlEvents.touchUpInside) return newTab }() lazy var privateModeButton: PrivateModeButton = { let privateModeButton = PrivateModeButton() privateModeButton.light = true privateModeButton.addTarget(self, action: #selector(TopTabsViewController.togglePrivateModeTapped), for: UIControlEvents.touchUpInside) return privateModeButton }() fileprivate lazy var tabLayoutDelegate: TopTabsLayoutDelegate = { let delegate = TopTabsLayoutDelegate() delegate.tabSelectionDelegate = self return delegate }() fileprivate var tabsToDisplay: [Tab] { return self.isPrivate ? tabManager.privateTabs : tabManager.normalTabs } // Handle animations. fileprivate var tabStore: [Tab] = [] //the actual datastore fileprivate var pendingUpdatesToTabs: [Tab] = [] //the datastore we are transitioning to fileprivate var needReloads: [Tab?] = [] // Tabs that need to be reloaded fileprivate var isUpdating = false fileprivate var pendingReloadData = false fileprivate var oldTabs: [Tab]? // The last state of the tabs before an animation fileprivate weak var oldSelectedTab: Tab? // Used to select the right tab when transitioning between private/normal tabs init(tabManager: TabManager) { self.tabManager = tabManager super.init(nibName: nil, bundle: nil) collectionView.dataSource = self collectionView.delegate = tabLayoutDelegate [UICollectionElementKindSectionHeader, UICollectionElementKindSectionFooter].forEach { collectionView.register(TopTabsHeaderFooter.self, forSupplementaryViewOfKind: $0, withReuseIdentifier: "HeaderFooter") } NotificationCenter.default.addObserver(self, selector: #selector(TopTabsViewController.reloadFavicons(_:)), name: faviconNotification, object: nil) } deinit { self.tabManager.removeDelegate(self) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if self.tabsToDisplay != self.tabStore { self.reloadData() } } override func viewDidLoad() { super.viewDidLoad() tabManager.addDelegate(self) self.tabStore = self.tabsToDisplay let topTabFader = TopTabFader() view.addSubview(topTabFader) topTabFader.addSubview(collectionView) view.addSubview(tabsButton) view.addSubview(newTab) view.addSubview(privateModeButton) newTab.snp.makeConstraints { make in make.centerY.equalTo(view) make.trailing.equalTo(tabsButton.snp.leading).offset(-10) make.size.equalTo(view.snp.height) } tabsButton.snp.makeConstraints { make in make.centerY.equalTo(view) make.trailing.equalTo(view).offset(-10) make.size.equalTo(view.snp.height) } privateModeButton.snp.makeConstraints { make in make.centerY.equalTo(view) make.leading.equalTo(view).offset(10) make.size.equalTo(view.snp.height) } topTabFader.snp.makeConstraints { make in make.top.bottom.equalTo(view) make.leading.equalTo(privateModeButton.snp.trailing) make.trailing.equalTo(newTab.snp.leading) } collectionView.snp.makeConstraints { make in make.edges.equalTo(topTabFader) } view.backgroundColor = UIColor(rgb: 0x272727) tabsButton.applyTheme(Theme.NormalMode) if let currentTab = tabManager.selectedTab { applyTheme(currentTab.isPrivate ? Theme.PrivateMode : Theme.NormalMode) } updateTabCount(tabStore.count, animated: false) } func switchForegroundStatus(isInForeground reveal: Bool) { // Called when the app leaves the foreground to make sure no information is inadvertently revealed if let cells = self.collectionView.visibleCells as? [TopTabCell] { let alpha: CGFloat = reveal ? 1 : 0 for cell in cells { cell.titleText.alpha = alpha cell.favicon.alpha = alpha } } } func updateTabCount(_ count: Int, animated: Bool = true) { self.tabsButton.updateTabCount(count, animated: animated) } func tabsTrayTapped() { delegate?.topTabsDidPressTabs() } func newTabTapped() { if pendingReloadData { return } self.delegate?.topTabsDidPressNewTab(self.isPrivate) LeanPlumClient.shared.track(event: .openedNewTab, withParameters: ["Source": "Add tab button in the URL Bar on iPad" as AnyObject]) } func togglePrivateModeTapped() { if isUpdating || pendingReloadData { return } let isPrivate = self.isPrivate delegate?.topTabsDidTogglePrivateMode() self.pendingReloadData = true // Stops animations from happening let oldSelectedTab = self.oldSelectedTab self.oldSelectedTab = tabManager.selectedTab self.privateModeButton.setSelected(!isPrivate, animated: true) //if private tabs is empty and we are transitioning to it add a tab if tabManager.privateTabs.isEmpty && !isPrivate { tabManager.addTab(isPrivate: true) } //get the tabs from which we will select which one to nominate for tribute (selection) //the isPrivate boolean still hasnt been flipped. (It'll be flipped in the BVC didSelectedTabChange method) let tabs = !isPrivate ? tabManager.privateTabs : tabManager.normalTabs if let tab = oldSelectedTab, tabs.index(of: tab) != nil { tabManager.selectTab(tab) } else { tabManager.selectTab(tabs.last) } } func reloadFavicons(_ notification: Notification) { // Notifications might be called from a different thread. Make sure animations only happen on the main thread. DispatchQueue.main.async { if let tab = notification.object as? Tab, self.tabStore.index(of: tab) != nil { self.needReloads.append(tab) self.performTabUpdates() } } } func scrollToCurrentTab(_ animated: Bool = true, centerCell: Bool = false) { assertIsMainThread("Only animate on the main thread") guard let currentTab = tabManager.selectedTab, let index = tabStore.index(of: currentTab), !collectionView.frame.isEmpty else { return } if let frame = collectionView.layoutAttributesForItem(at: IndexPath(row: index, section: 0))?.frame { if centerCell { collectionView.scrollToItem(at: IndexPath(item: index, section: 0), at: .centeredHorizontally, animated: false) } else { // Padding is added to ensure the tab is completely visible (none of the tab is under the fader) let padFrame = frame.insetBy(dx: -(TopTabsUX.TopTabsBackgroundShadowWidth+TopTabsUX.FaderPading), dy: 0) if animated { UIView.animate(withDuration: TopTabsUX.AnimationSpeed, animations: { self.collectionView.scrollRectToVisible(padFrame, animated: true) }) } else { collectionView.scrollRectToVisible(padFrame, animated: false) } } } } } extension TopTabsViewController: Themeable { func applyTheme(_ themeName: String) { tabsButton.applyTheme(themeName) tabsButton.titleBackgroundColor = view.backgroundColor ?? UIColor(rgb: 0x272727) tabsButton.textColor = UIColor(rgb: 0xb1b1b3) isPrivate = (themeName == Theme.PrivateMode) privateModeButton.styleForMode(privateMode: isPrivate) privateModeButton.tintColor = isPrivate ? UIColor(rgb: 0xf9f9fa) : UIColor(rgb: 0xb1b1b3) privateModeButton.imageView?.tintColor = privateModeButton.tintColor newTab.tintColor = UIColor(rgb: 0xb1b1b3) collectionView.backgroundColor = view.backgroundColor } } extension TopTabsViewController: TopTabCellDelegate { func tabCellDidClose(_ cell: TopTabCell) { // Trying to remove tabs while animating can lead to crashes as indexes change. If updates are happening don't allow tabs to be removed. guard let index = collectionView.indexPath(for: cell)?.item else { return } let tab = tabStore[index] if tabsToDisplay.index(of: tab) != nil { tabManager.removeTab(tab) } } } extension TopTabsViewController: UICollectionViewDataSource { @objc func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let index = indexPath.item let tabCell = collectionView.dequeueReusableCell(withReuseIdentifier: TopTabCell.Identifier, for: indexPath) as! TopTabCell tabCell.delegate = self let tab = tabStore[index] tabCell.style = tab.isPrivate ? .dark : .light tabCell.titleText.text = tab.displayTitle if tab.displayTitle.isEmpty { if tab.webView?.url?.baseDomain?.contains("localhost") ?? true { tabCell.titleText.text = Strings.AppMenuNewTabTitleString } else { tabCell.titleText.text = tab.webView?.url?.absoluteDisplayString } tabCell.accessibilityLabel = tab.url?.aboutComponent ?? "" tabCell.closeButton.accessibilityLabel = String(format: Strings.TopSitesRemoveButtonAccessibilityLabel, tabCell.titleText.text ?? "") } else { tabCell.accessibilityLabel = tab.displayTitle tabCell.closeButton.accessibilityLabel = String(format: Strings.TopSitesRemoveButtonAccessibilityLabel, tab.displayTitle) } tabCell.selectedTab = (tab == tabManager.selectedTab) if let siteURL = tab.url?.displayURL { tabCell.favicon.setIcon(tab.displayFavicon, forURL: siteURL, completed: { (color, url) in if siteURL == url { tabCell.favicon.image = tabCell.favicon.image?.createScaled(CGSize(width: 15, height: 15)) tabCell.favicon.backgroundColor = color == .clear ? .white : color tabCell.favicon.contentMode = .center } }) } else { tabCell.favicon.image = UIImage(named: "defaultFavicon") tabCell.favicon.contentMode = .scaleAspectFit tabCell.favicon.backgroundColor = .clear } return tabCell } @objc func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return tabStore.count } @objc func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "HeaderFooter", for: indexPath) as! TopTabsHeaderFooter view.arrangeLine(kind) return view } } extension TopTabsViewController: TabSelectionDelegate { func didSelectTabAtIndex(_ index: Int) { let tab = tabStore[index] if tabsToDisplay.index(of: tab) != nil { tabManager.selectTab(tab) } } } // Collection Diff (animations) extension TopTabsViewController { struct TopTabChangeSet { let reloads: Set let inserts: Set let deletes: Set init(reloadArr: [IndexPath], insertArr: [IndexPath], deleteArr: [IndexPath]) { reloads = Set(reloadArr) inserts = Set(insertArr) deletes = Set(deleteArr) } var all: [Set] { return [inserts, reloads, deletes] } } // create a TopTabChangeSet which is a snapshot of updates to perfrom on a collectionView func calculateDiffWith(_ oldTabs: [Tab], to newTabs: [Tab], and reloadTabs: [Tab?]) -> TopTabChangeSet { let inserts: [IndexPath] = newTabs.enumerated().flatMap { index, tab in if oldTabs.index(of: tab) == nil { return IndexPath(row: index, section: 0) } return nil } let deletes: [IndexPath] = oldTabs.enumerated().flatMap { index, tab in if newTabs.index(of: tab) == nil { return IndexPath(row: index, section: 0) } return nil } // Create based on what is visibile but filter out tabs we are about to insert/delete. let reloads: [IndexPath] = reloadTabs.flatMap { tab in guard let tab = tab, newTabs.index(of: tab) != nil else { return nil } return IndexPath(row: newTabs.index(of: tab)!, section: 0) }.filter { return inserts.index(of: $0) == nil && deletes.index(of: $0) == nil } return TopTabChangeSet(reloadArr: reloads, insertArr: inserts, deleteArr: deletes) } func updateTabsFrom(_ oldTabs: [Tab]?, to newTabs: [Tab], on completion: (() -> Void)? = nil) { assertIsMainThread("Updates can only be performed from the main thread") guard let oldTabs = oldTabs, !self.isUpdating, !self.pendingReloadData else { return } // Lets create our change set let update = self.calculateDiffWith(oldTabs, to: newTabs, and: needReloads) flushPendingChanges() // If there are no changes. We have nothing to do if update.all.every({ $0.isEmpty }) { completion?() return } // The actual update block. We update the dataStore right before we do the UI updates. let updateBlock = { self.tabStore = newTabs self.collectionView.deleteItems(at: Array(update.deletes)) self.collectionView.insertItems(at: Array(update.inserts)) self.collectionView.reloadItems(at: Array(update.reloads)) } //Lets lock any other updates from happening. self.isUpdating = true self.pendingUpdatesToTabs = newTabs // This var helps other mutations that might happen while updating. // The actual update UIView.animate(withDuration: TopTabsUX.AnimationSpeed, animations: { self.collectionView.performBatchUpdates(updateBlock) }) { (_) in self.isUpdating = false self.pendingUpdatesToTabs = [] // Sometimes there might be a pending reload. Lets do that. if self.pendingReloadData { return self.reloadData() } // There can be pending animations. Run update again to clear them. let tabs = self.oldTabs ?? self.tabStore self.updateTabsFrom(tabs, to: self.tabsToDisplay, on: { if !update.inserts.isEmpty || !update.reloads.isEmpty { self.scrollToCurrentTab() } }) } } fileprivate func flushPendingChanges() { oldTabs = nil needReloads.removeAll() } fileprivate func reloadData() { assertIsMainThread("reloadData must only be called from main thread") if self.isUpdating || self.collectionView.frame == CGRect.zero { self.pendingReloadData = true return } isUpdating = true self.tabStore = self.tabsToDisplay self.newTab.isUserInteractionEnabled = false self.flushPendingChanges() UIView.animate(withDuration: TopTabsUX.AnimationSpeed, animations: { self.collectionView.reloadData() self.collectionView.collectionViewLayout.invalidateLayout() self.collectionView.layoutIfNeeded() self.scrollToCurrentTab(true, centerCell: true) }, completion: { (_) in self.isUpdating = false self.pendingReloadData = false self.performTabUpdates() self.newTab.isUserInteractionEnabled = true }) } } extension TopTabsViewController: TabManagerDelegate { // Because we don't know when we are about to transition to private mode // check to make sure that the tab we are trying to add is being added to the right tab group fileprivate func tabsMatchDisplayGroup(_ a: Tab?, b: Tab?) -> Bool { if let a = a, let b = b, a.isPrivate == b.isPrivate { return true } return false } func performTabUpdates() { guard !isUpdating else { return } let fromTabs = !self.pendingUpdatesToTabs.isEmpty ? self.pendingUpdatesToTabs : self.oldTabs self.oldTabs = fromTabs ?? self.tabStore if self.pendingReloadData && !isUpdating { self.reloadData() } else { self.updateTabsFrom(self.oldTabs, to: self.tabsToDisplay) } } // This helps make sure animations don't happen before the view is loaded. fileprivate var isRestoring: Bool { return self.tabManager.isRestoring || self.collectionView.frame == CGRect.zero } func tabManager(_ tabManager: TabManager, didSelectedTabChange selected: Tab?, previous: Tab?) { if isRestoring { return } if !tabsMatchDisplayGroup(selected, b: previous) { self.reloadData() } else { self.needReloads.append(selected) self.needReloads.append(previous) performTabUpdates() delegate?.topTabsDidChangeTab() } } func tabManager(_ tabManager: TabManager, willAddTab tab: Tab) { // We need to store the earliest oldTabs. So if one already exists use that. self.oldTabs = self.oldTabs ?? tabStore } func tabManager(_ tabManager: TabManager, didAddTab tab: Tab) { if isRestoring || (tabManager.selectedTab != nil && !tabsMatchDisplayGroup(tab, b: tabManager.selectedTab)) { return } performTabUpdates() } func tabManager(_ tabManager: TabManager, willRemoveTab tab: Tab) { // We need to store the earliest oldTabs. So if one already exists use that. self.oldTabs = self.oldTabs ?? tabStore } func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab) { if isRestoring { return } // If we deleted the last private tab. We'll be switching back to normal browsing. Pause updates till then if self.tabsToDisplay.isEmpty { self.pendingReloadData = true return } // dont want to hold a ref to a deleted tab if tab === oldSelectedTab { oldSelectedTab = nil } performTabUpdates() } func tabManagerDidRestoreTabs(_ tabManager: TabManager) { self.reloadData() } func tabManagerDidAddTabs(_ tabManager: TabManager) { self.reloadData() } func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) { self.reloadData() } }