/* 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 UIKit import SnapKit import Storage import ReadingList import Shared struct TabTrayControllerUX { static let CornerRadius = CGFloat(6.0) static let BackgroundColor = UIConstants.TabTrayBG static let CellBackgroundColor = UIConstants.TabTrayBG static let TextBoxHeight = CGFloat(32.0) static let FaviconSize = CGFloat(20) static let Margin = CGFloat(15) static let ToolbarBarTintColor = UIColor.black static let ToolbarButtonOffset = CGFloat(10.0) static let CloseButtonSize = CGFloat(18.0) static let CloseButtonMargin = CGFloat(6.0) static let CloseButtonEdgeInset = CGFloat(10) static let NumberOfColumnsThin = 1 static let NumberOfColumnsWide = 3 static let CompactNumberOfColumnsThin = 2 static let MenuFixedWidth: CGFloat = 320 } struct LightTabCellUX { static let TabTitleTextColor = UIColor.black } struct DarkTabCellUX { static let TabTitleTextColor = UIColor.white } protocol TabCellDelegate: class { func tabCellDidClose(_ cell: TabCell) } class TabCell: UICollectionViewCell { enum Style { case light case dark } static let Identifier = "TabCellIdentifier" static let BorderWidth: CGFloat = 3 var style: Style = .light { didSet { applyStyle(style) } } let backgroundHolder = UIView() let screenshotView = UIImageViewAligned() let titleText: UILabel let favicon: UIImageView = UIImageView() let closeButton: UIButton var title: UIVisualEffectView! var animator: SwipeAnimator! weak var delegate: TabCellDelegate? // Changes depending on whether we're full-screen or not. var margin = CGFloat(0) override init(frame: CGRect) { self.backgroundHolder.backgroundColor = UIColor.white self.backgroundHolder.layer.cornerRadius = TabTrayControllerUX.CornerRadius self.backgroundHolder.clipsToBounds = true self.backgroundHolder.backgroundColor = TabTrayControllerUX.CellBackgroundColor self.screenshotView.contentMode = UIViewContentMode.scaleAspectFill self.screenshotView.clipsToBounds = true self.screenshotView.isUserInteractionEnabled = false self.screenshotView.alignLeft = true self.screenshotView.alignTop = true screenshotView.backgroundColor = UIConstants.AppBackgroundColor self.favicon.backgroundColor = UIColor.clear self.favicon.layer.cornerRadius = 2.0 self.favicon.layer.masksToBounds = true self.titleText = UILabel() self.titleText.textAlignment = NSTextAlignment.left self.titleText.isUserInteractionEnabled = false self.titleText.numberOfLines = 1 self.titleText.font = DynamicFontHelper.defaultHelper.DefaultSmallFontBold self.closeButton = UIButton() self.closeButton.setImage(UIImage.templateImageNamed("nav-stop"), for: UIControlState()) self.closeButton.tintColor = UIColor.lightGray self.closeButton.imageEdgeInsets = UIEdgeInsets(equalInset: TabTrayControllerUX.CloseButtonEdgeInset) super.init(frame: frame) self.animator = SwipeAnimator(animatingView: self) self.closeButton.addTarget(self, action: #selector(TabCell.close), for: UIControlEvents.touchUpInside) contentView.addSubview(backgroundHolder) backgroundHolder.addSubview(self.screenshotView) // Default style is light applyStyle(style) self.accessibilityCustomActions = [ UIAccessibilityCustomAction(name: NSLocalizedString("Close", comment: "Accessibility label for action denoting closing a tab in tab list (tray)"), target: self.animator, selector: #selector(SwipeAnimator.closeWithoutGesture)) ] } fileprivate func applyStyle(_ style: Style) { self.title?.removeFromSuperview() let title: UIVisualEffectView switch style { case .light: title = UIVisualEffectView(effect: UIBlurEffect(style: .extraLight)) self.titleText.textColor = LightTabCellUX.TabTitleTextColor case .dark: title = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) self.titleText.textColor = DarkTabCellUX.TabTitleTextColor } titleText.backgroundColor = UIColor.clear title.contentView.addSubview(self.closeButton) title.contentView.addSubview(self.titleText) title.contentView.addSubview(self.favicon) backgroundHolder.addSubview(title) self.title = title } func setTabSelected(_ isPrivate: Bool) { // This creates a border around a tabcell. Using the shadow craetes a border _outside_ of the tab frame. layer.shadowColor = (isPrivate ? UIConstants.PrivateModePurple : UIConstants.SystemBlueColor).cgColor layer.shadowOpacity = 1 layer.shadowRadius = 0 // A 0 radius creates a solid border instead of a gradient blur layer.masksToBounds = false // create a frame that is "BorderWidth" size bigger than the cell layer.shadowOffset = CGSize(width: -TabCell.BorderWidth, height: -TabCell.BorderWidth) let shadowPath = CGRect(x: 0, y: 0, width: layer.frame.width + (TabCell.BorderWidth * 2), height: layer.frame.height + (TabCell.BorderWidth * 2)) layer.shadowPath = UIBezierPath(roundedRect: shadowPath, cornerRadius: TabTrayControllerUX.CornerRadius+TabCell.BorderWidth).cgPath } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() let w = frame.width let h = frame.height backgroundHolder.frame = CGRect(x: margin, y: margin, width: w, height: h) screenshotView.frame = CGRect(origin: CGPoint(x: 0, y: 0), size: backgroundHolder.frame.size) title.frame = CGRect(x: 0, y: 0, width: backgroundHolder.frame.width, height: TabTrayControllerUX.TextBoxHeight) favicon.frame = CGRect(x: 6, y: (TabTrayControllerUX.TextBoxHeight - TabTrayControllerUX.FaviconSize)/2, width: TabTrayControllerUX.FaviconSize, height: TabTrayControllerUX.FaviconSize) let titleTextLeft = favicon.frame.origin.x + favicon.frame.width + 6 titleText.frame = CGRect(x: titleTextLeft, y: 0, width: title.frame.width - titleTextLeft - margin - TabTrayControllerUX.CloseButtonSize - TabTrayControllerUX.CloseButtonMargin * 2, height: title.frame.height) closeButton.snp.makeConstraints { make in make.size.equalTo(title.snp.height) make.trailing.centerY.equalTo(title) } let top = (TabTrayControllerUX.TextBoxHeight - titleText.bounds.height) / 2.0 titleText.frame.origin = CGPoint(x: titleText.frame.origin.x, y: max(0, top)) let shadowPath = CGRect(x: 0, y: 0, width: layer.frame.width + (TabCell.BorderWidth * 2), height: layer.frame.height + (TabCell.BorderWidth * 2)) layer.shadowPath = UIBezierPath(roundedRect: shadowPath, cornerRadius: TabTrayControllerUX.CornerRadius+TabCell.BorderWidth).cgPath } override func prepareForReuse() { // Reset any close animations. backgroundHolder.transform = CGAffineTransform.identity backgroundHolder.alpha = 1 self.titleText.font = DynamicFontHelper.defaultHelper.DefaultSmallFontBold layer.shadowOffset = CGSize.zero layer.shadowPath = nil layer.shadowOpacity = 0 } override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool { var right: Bool switch direction { case .left: right = false case .right: right = true default: return false } animator.close(right: right) return true } @objc func close() { self.animator.closeWithoutGesture() } } struct PrivateModeStrings { static let toggleAccessibilityLabel = NSLocalizedString("Private Mode", tableName: "PrivateBrowsing", comment: "Accessibility label for toggling on/off private mode") static let toggleAccessibilityHint = NSLocalizedString("Turns private mode on or off", tableName: "PrivateBrowsing", comment: "Accessiblity hint for toggling on/off private mode") static let toggleAccessibilityValueOn = NSLocalizedString("On", tableName: "PrivateBrowsing", comment: "Toggled ON accessibility value") static let toggleAccessibilityValueOff = NSLocalizedString("Off", tableName: "PrivateBrowsing", comment: "Toggled OFF accessibility value") } protocol TabTrayDelegate: class { func tabTrayDidDismiss(_ tabTray: TabTrayController) func tabTrayDidAddBookmark(_ tab: Tab) func tabTrayDidAddToReadingList(_ tab: Tab) -> ReadingListClientRecord? func tabTrayRequestsPresentationOf(_ viewController: UIViewController) } class TabTrayController: UIViewController { let tabManager: TabManager let profile: Profile weak var delegate: TabTrayDelegate? var collectionView: UICollectionView! var draggedCell: TabCell? var dragOffset: CGPoint = CGPoint.zero lazy var toolbar: TrayToolbar = { let toolbar = TrayToolbar() toolbar.addTabButton.addTarget(self, action: #selector(TabTrayController.didClickAddTab), for: .touchUpInside) toolbar.maskButton.addTarget(self, action: #selector(TabTrayController.didTogglePrivateMode), for: .touchUpInside) toolbar.deleteButton.addTarget(self, action: #selector(TabTrayController.didTapDelete(_:)), for: .touchUpInside) return toolbar }() fileprivate(set) internal var privateMode: Bool = false { didSet { tabDataSource.tabs = tabsToDisplay toolbar.styleToolbar(privateMode) collectionView?.reloadData() } } fileprivate var tabsToDisplay: [Tab] { return self.privateMode ? tabManager.privateTabs : tabManager.normalTabs } fileprivate lazy var emptyPrivateTabsView: EmptyPrivateTabsView = { let emptyView = EmptyPrivateTabsView() emptyView.learnMoreButton.addTarget(self, action: #selector(TabTrayController.didTapLearnMore), for: UIControlEvents.touchUpInside) return emptyView }() fileprivate lazy var tabDataSource: TabManagerDataSource = { return TabManagerDataSource(tabs: self.tabsToDisplay, cellDelegate: self, tabManager: self.tabManager) }() fileprivate lazy var tabLayoutDelegate: TabLayoutDelegate = { let delegate = TabLayoutDelegate(profile: self.profile, traitCollection: self.traitCollection) delegate.tabSelectionDelegate = self return delegate }() init(tabManager: TabManager, profile: Profile) { self.tabManager = tabManager self.profile = profile super.init(nibName: nil, bundle: nil) tabManager.addDelegate(self) } convenience init(tabManager: TabManager, profile: Profile, tabTrayDelegate: TabTrayDelegate) { self.init(tabManager: tabManager, profile: profile) self.delegate = tabTrayDelegate } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.tabManager.removeDelegate(self) } func dynamicFontChanged(_ notification: Notification) { guard notification.name == NotificationDynamicFontChanged else { return } self.collectionView.reloadData() } // MARK: View Controller Callbacks override func viewDidLoad() { super.viewDidLoad() view.accessibilityLabel = NSLocalizedString("Tabs Tray", comment: "Accessibility label for the Tabs Tray view.") collectionView = UICollectionView(frame: view.frame, collectionViewLayout: UICollectionViewFlowLayout()) collectionView.dataSource = tabDataSource collectionView.delegate = tabLayoutDelegate collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: UIConstants.BottomToolbarHeight, right: 0) collectionView.register(TabCell.self, forCellWithReuseIdentifier: TabCell.Identifier) collectionView.backgroundColor = TabTrayControllerUX.BackgroundColor view.addSubview(collectionView) view.addSubview(toolbar) makeConstraints() view.insertSubview(emptyPrivateTabsView, aboveSubview: collectionView) emptyPrivateTabsView.snp.makeConstraints { make in make.top.left.right.equalTo(self.collectionView) make.bottom.equalTo(self.toolbar.snp.top) } if let tab = tabManager.selectedTab, tab.isPrivate { privateMode = true } // register for previewing delegate to enable peek and pop if force touch feature available if traitCollection.forceTouchCapability == .available { registerForPreviewing(with: self, sourceView: view) } emptyPrivateTabsView.isHidden = !privateTabsAreEmpty() NotificationCenter.default.addObserver(self, selector: #selector(TabTrayController.appWillResignActiveNotification), name: NSNotification.Name.UIApplicationWillResignActive, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(TabTrayController.appDidBecomeActiveNotification), name: NSNotification.Name.UIApplicationDidBecomeActive, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(TabTrayController.dynamicFontChanged(_:)), name: NotificationDynamicFontChanged, object: nil) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) // Update the trait collection we reference in our layout delegate tabLayoutDelegate.traitCollection = traitCollection self.collectionView.collectionViewLayout.invalidateLayout() } fileprivate func cancelExistingGestures() { if let visibleCells = self.collectionView.visibleCells as? [TabCell] { for cell in visibleCells { cell.animator.cancelExistingGestures() } } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { _ in self.collectionView.collectionViewLayout.invalidateLayout() }, completion: nil) } override var preferredStatusBarStyle: UIStatusBarStyle { return UIStatusBarStyle.lightContent //this will need to be fixed } fileprivate func makeConstraints() { collectionView.snp.makeConstraints { make in make.left.bottom.right.equalTo(view) make.top.equalTo(self.topLayoutGuide.snp.bottom) } toolbar.snp.makeConstraints { make in make.left.right.bottom.equalTo(view) make.height.equalTo(UIConstants.BottomToolbarHeight) } } // MARK: Selectors func didClickDone() { presentingViewController!.dismiss(animated: true, completion: nil) } func didClickSettingsItem() { assert(Thread.isMainThread, "Opening settings requires being invoked on the main thread") let settingsTableViewController = AppSettingsTableViewController() settingsTableViewController.profile = profile settingsTableViewController.tabManager = tabManager settingsTableViewController.settingsDelegate = self let controller = SettingsNavigationController(rootViewController: settingsTableViewController) controller.popoverDelegate = self controller.modalPresentationStyle = UIModalPresentationStyle.formSheet present(controller, animated: true, completion: nil) } func didClickAddTab() { openNewTab() LeanPlumClient.shared.track(event: .openedNewTab, withParameters: ["Source": "Tab Tray" as AnyObject]) } func didTapLearnMore() { let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String if let langID = Locale.preferredLanguages.first { let learnMoreRequest = URLRequest(url: "https://support.mozilla.org/1/mobile/\(appVersion)/iOS/\(langID)/private-browsing-ios".asURL!) openNewTab(learnMoreRequest) } } func didTogglePrivateMode() { let scaleDownTransform = CGAffineTransform(scaleX: 0.9, y: 0.9) let fromView: UIView if !privateTabsAreEmpty(), let snapshot = collectionView.snapshotView(afterScreenUpdates: false) { snapshot.frame = collectionView.frame view.insertSubview(snapshot, aboveSubview: collectionView) fromView = snapshot } else { fromView = emptyPrivateTabsView } tabManager.willSwitchTabMode() privateMode = !privateMode // If we are exiting private mode and we have the close private tabs option selected, make sure // we clear out all of the private tabs let exitingPrivateMode = !privateMode && tabManager.shouldClearPrivateTabs() toolbar.maskButton.setSelected(privateMode, animated: true) collectionView.layoutSubviews() let toView: UIView if !privateTabsAreEmpty(), let newSnapshot = collectionView.snapshotView(afterScreenUpdates: !exitingPrivateMode) { emptyPrivateTabsView.isHidden = true //when exiting private mode don't screenshot the collectionview (causes the UI to hang) newSnapshot.frame = collectionView.frame view.insertSubview(newSnapshot, aboveSubview: fromView) collectionView.alpha = 0 toView = newSnapshot } else { emptyPrivateTabsView.isHidden = false toView = emptyPrivateTabsView } toView.alpha = 0 toView.transform = scaleDownTransform UIView.animate(withDuration: 0.2, delay: 0, options: [], animations: { () -> Void in fromView.transform = scaleDownTransform fromView.alpha = 0 toView.transform = CGAffineTransform.identity toView.alpha = 1 }) { finished in if fromView != self.emptyPrivateTabsView { fromView.removeFromSuperview() } if toView != self.emptyPrivateTabsView { toView.removeFromSuperview() } self.collectionView.alpha = 1 } } fileprivate func privateTabsAreEmpty() -> Bool { return privateMode && tabManager.privateTabs.count == 0 } func changePrivacyMode(_ isPrivate: Bool) { if isPrivate != privateMode { guard let _ = collectionView else { privateMode = isPrivate return } didTogglePrivateMode() } } fileprivate func openNewTab(_ request: URLRequest? = nil) { toolbar.isUserInteractionEnabled = false // We're only doing one update here, but using a batch update lets us delay selecting the tab // until after its insert animation finishes. self.collectionView.performBatchUpdates({ _ in _ = self.tabManager.addTab(request, isPrivate: self.privateMode) }, completion: { finished in // The addTab delegate method will pop to the BVC no need to do anything here. self.toolbar.isUserInteractionEnabled = true if finished { if request == nil && NewTabAccessors.getNewTabPage(self.profile.prefs) == .blankPage { if let bvc = self.navigationController?.topViewController as? BrowserViewController { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { bvc.urlBar.tabLocationViewDidTapLocation(bvc.urlBar.locationView) } } } } }) } fileprivate func closeTabsForCurrentTray() { tabManager.removeTabsWithUndoToast(tabsToDisplay) self.collectionView.reloadData() } } // MARK: - App Notifications extension TabTrayController { func appWillResignActiveNotification() { if privateMode { collectionView.alpha = 0 } } func appDidBecomeActiveNotification() { // Re-show any components that might have been hidden because they were being displayed // as part of a private mode tab UIView.animate(withDuration: 0.2, delay: 0, options: UIViewAnimationOptions(), animations: { self.collectionView.alpha = 1 }, completion: nil) } } extension TabTrayController: TabSelectionDelegate { func didSelectTabAtIndex(_ index: Int) { let tab = tabsToDisplay[index] tabManager.selectTab(tab) _ = self.navigationController?.popViewController(animated: true) } } extension TabTrayController: PresentingModalViewControllerDelegate { func dismissPresentedModalViewController(_ modalViewController: UIViewController, animated: Bool) { dismiss(animated: animated, completion: { self.collectionView.reloadData() }) } } extension TabTrayController: TabManagerDelegate { func tabManager(_ tabManager: TabManager, didSelectedTabChange selected: Tab?, previous: Tab?) { } func tabManager(_ tabManager: TabManager, willAddTab tab: Tab) { } func tabManager(_ tabManager: TabManager, willRemoveTab tab: Tab) { } func tabManager(_ tabManager: TabManager, didAddTab tab: Tab) { // Get the index of the added tab from it's set (private or normal) guard let index = tabsToDisplay.index(of: tab) else { return } if !privateTabsAreEmpty() { emptyPrivateTabsView.isHidden = true } tabDataSource.addTab(tab) self.collectionView?.performBatchUpdates({ _ in self.collectionView.insertItems(at: [IndexPath(item: index, section: 0)]) }, completion: { finished in if finished { tabManager.selectTab(tab) // don't pop the tab tray view controller if it is not in the foreground if self.presentedViewController == nil { _ = self.navigationController?.popViewController(animated: true) } } }) } func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab) { // it is possible that we are removing a tab that we are not currently displaying // through the Close All Tabs feature (which will close tabs that are not in our current privacy mode) // check this before removing the item from the collection let removedIndex = tabDataSource.removeTab(tab) if removedIndex > -1 { self.collectionView.performBatchUpdates({ self.collectionView.deleteItems(at: [IndexPath(item: removedIndex, section: 0)]) }, completion: { finished in guard finished && self.privateTabsAreEmpty() else { return } self.emptyPrivateTabsView.isHidden = false }) } } func tabManagerDidAddTabs(_ tabManager: TabManager) { } func tabManagerDidRestoreTabs(_ tabManager: TabManager) { } func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) { guard privateMode else { return } if let toast = toast { view.addSubview(toast) toast.snp.makeConstraints { make in make.left.right.equalTo(view) make.bottom.equalTo(toolbar.snp.top) } toast.showToast() } } } extension TabTrayController: UIScrollViewAccessibilityDelegate { func accessibilityScrollStatus(for scrollView: UIScrollView) -> String? { var visibleCells = collectionView.visibleCells as! [TabCell] var bounds = collectionView.bounds bounds = bounds.offsetBy(dx: collectionView.contentInset.left, dy: collectionView.contentInset.top) bounds.size.width -= collectionView.contentInset.left + collectionView.contentInset.right bounds.size.height -= collectionView.contentInset.top + collectionView.contentInset.bottom // visible cells do sometimes return also not visible cells when attempting to go past the last cell with VoiceOver right-flick gesture; so make sure we have only visible cells (yeah...) visibleCells = visibleCells.filter { !$0.frame.intersection(bounds).isEmpty } let cells = visibleCells.map { self.collectionView.indexPath(for: $0)! } let indexPaths = cells.sorted { (a: IndexPath, b: IndexPath) -> Bool in return a.section < b.section || (a.section == b.section && a.row < b.row) } if indexPaths.count == 0 { return NSLocalizedString("No tabs", comment: "Message spoken by VoiceOver to indicate that there are no tabs in the Tabs Tray") } let firstTab = indexPaths.first!.row + 1 let lastTab = indexPaths.last!.row + 1 let tabCount = collectionView.numberOfItems(inSection: 0) if firstTab == lastTab { let format = NSLocalizedString("Tab %@ of %@", comment: "Message spoken by VoiceOver saying the position of the single currently visible tab in Tabs Tray, along with the total number of tabs. E.g. \"Tab 2 of 5\" says that tab 2 is visible (and is the only visible tab), out of 5 tabs total.") return String(format: format, NSNumber(value: firstTab as Int), NSNumber(value: tabCount as Int)) } else { let format = NSLocalizedString("Tabs %@ to %@ of %@", comment: "Message spoken by VoiceOver saying the range of tabs that are currently visible in Tabs Tray, along with the total number of tabs. E.g. \"Tabs 8 to 10 of 15\" says tabs 8, 9 and 10 are visible, out of 15 tabs total.") return String(format: format, NSNumber(value: firstTab as Int), NSNumber(value: lastTab as Int), NSNumber(value: tabCount as Int)) } } } extension TabTrayController: SwipeAnimatorDelegate { func swipeAnimator(_ animator: SwipeAnimator, viewWillExitContainerBounds: UIView) { let tabCell = animator.animatingView as! TabCell if let indexPath = collectionView.indexPath(for: tabCell) { let tab = tabsToDisplay[indexPath.item] tabManager.removeTab(tab) UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, NSLocalizedString("Closing tab", comment: "Accessibility label (used by assistive technology) notifying the user that the tab is being closed.")) } } } extension TabTrayController: TabCellDelegate { func tabCellDidClose(_ cell: TabCell) { let indexPath = collectionView.indexPath(for: cell)! let tab = tabsToDisplay[indexPath.item] tabManager.removeTab(tab) } } extension TabTrayController: SettingsDelegate { func settingsOpenURLInNewTab(_ url: URL) { let request = URLRequest(url: url) openNewTab(request) } } extension TabTrayController: PhotonActionSheetProtocol { func didTapDelete(_ sender: UIButton) { let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) controller.addAction(UIAlertAction(title: Strings.AppMenuCloseAllTabsTitleString, style: .default, handler: { _ in self.closeTabsForCurrentTray() })) controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Label for Cancel button"), style: .cancel, handler: nil)) controller.popoverPresentationController?.sourceView = sender controller.popoverPresentationController?.sourceRect = sender.bounds present(controller, animated: true, completion: nil) } } fileprivate class TabManagerDataSource: NSObject, UICollectionViewDataSource { unowned var cellDelegate: TabCellDelegate & SwipeAnimatorDelegate fileprivate var tabs: [Tab] fileprivate var tabManager: TabManager init(tabs: [Tab], cellDelegate: TabCellDelegate & SwipeAnimatorDelegate, tabManager: TabManager) { self.cellDelegate = cellDelegate self.tabs = tabs self.tabManager = tabManager super.init() } /** Removes the given tab from the data source - parameter tab: Tab to remove - returns: The index of the removed tab, -1 if tab did not exist */ func removeTab(_ tabToRemove: Tab) -> Int { var index: Int = -1 for (i, tab) in tabs.enumerated() where tabToRemove === tab { index = i tabs.remove(at: index) break } return index } /** Adds the given tab to the data source - parameter tab: Tab to add */ func addTab(_ tab: Tab) { tabs.append(tab) } @objc func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let tabCell = collectionView.dequeueReusableCell(withReuseIdentifier: TabCell.Identifier, for: indexPath) as! TabCell tabCell.animator.delegate = cellDelegate tabCell.delegate = cellDelegate let tab = tabs[indexPath.item] tabCell.style = tab.isPrivate ? .dark : .light tabCell.titleText.text = tab.displayTitle tabCell.closeButton.tintColor = tab.isPrivate ? UIColor.white : UIColor.gray if !tab.displayTitle.isEmpty { tabCell.accessibilityLabel = tab.displayTitle } else { tabCell.accessibilityLabel = tab.url?.aboutComponent ?? "" // If there is no title we are most likely on a home panel. } tabCell.isAccessibilityElement = true tabCell.accessibilityHint = NSLocalizedString("Swipe right or left with three fingers to close the tab.", comment: "Accessibility hint for tab tray's displayed tab.") if let favIcon = tab.displayFavicon { tabCell.favicon.sd_setImage(with: URL(string: favIcon.url)!) } else { let defaultFavicon = UIImage(named: "defaultFavicon") if tab.isPrivate { tabCell.favicon.image = defaultFavicon tabCell.favicon.tintColor = UIColor.white } else { tabCell.favicon.image = defaultFavicon } } if tab == tabManager.selectedTab { tabCell.setTabSelected(tab.isPrivate) } tabCell.screenshotView.image = tab.screenshot return tabCell } @objc func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return tabs.count } @objc fileprivate func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { let fromIndex = sourceIndexPath.item let toIndex = destinationIndexPath.item tabs.insert(tabs.remove(at: fromIndex), at: toIndex < fromIndex ? toIndex : toIndex - 1) tabManager.moveTab(isPrivate: tabs[fromIndex].isPrivate, fromIndex: fromIndex, toIndex: toIndex) } } @objc protocol TabSelectionDelegate: class { func didSelectTabAtIndex(_ index: Int) } fileprivate class TabLayoutDelegate: NSObject, UICollectionViewDelegateFlowLayout { weak var tabSelectionDelegate: TabSelectionDelegate? fileprivate var traitCollection: UITraitCollection fileprivate var profile: Profile fileprivate var numberOfColumns: Int { // iPhone 4-6+ portrait if traitCollection.horizontalSizeClass == .compact && traitCollection.verticalSizeClass == .regular { return TabTrayControllerUX.CompactNumberOfColumnsThin } else { return TabTrayControllerUX.NumberOfColumnsWide } } init(profile: Profile, traitCollection: UITraitCollection) { self.profile = profile self.traitCollection = traitCollection super.init() } fileprivate func cellHeightForCurrentDevice() -> CGFloat { let shortHeight = TabTrayControllerUX.TextBoxHeight * 6 if self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClass.compact { return shortHeight } else if self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClass.compact { return shortHeight } else { return TabTrayControllerUX.TextBoxHeight * 8 } } @objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return TabTrayControllerUX.Margin } @objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let cellWidth = floor((collectionView.bounds.width - TabTrayControllerUX.Margin * CGFloat(numberOfColumns + 1)) / CGFloat(numberOfColumns)) return CGSize(width: cellWidth, height: self.cellHeightForCurrentDevice()) } @objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { return UIEdgeInsets(equalInset: TabTrayControllerUX.Margin) } @objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { return TabTrayControllerUX.Margin } @objc func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { tabSelectionDelegate?.didSelectTabAtIndex(indexPath.row) } } struct EmptyPrivateTabsViewUX { static let TitleColor = UIColor.white static let TitleFont = UIFont.systemFont(ofSize: 22, weight: UIFontWeightMedium) static let DescriptionColor = UIColor.white static let DescriptionFont = UIFont.systemFont(ofSize: 17) static let LearnMoreFont = UIFont.systemFont(ofSize: 15, weight: UIFontWeightMedium) static let TextMargin: CGFloat = 18 static let LearnMoreMargin: CGFloat = 30 static let MaxDescriptionWidth: CGFloat = 250 static let MinBottomMargin: CGFloat = 10 } // View we display when there are no private tabs created fileprivate class EmptyPrivateTabsView: UIView { fileprivate lazy var titleLabel: UILabel = { let label = UILabel() label.textColor = EmptyPrivateTabsViewUX.TitleColor label.font = EmptyPrivateTabsViewUX.TitleFont label.textAlignment = NSTextAlignment.center return label }() fileprivate var descriptionLabel: UILabel = { let label = UILabel() label.textColor = EmptyPrivateTabsViewUX.DescriptionColor label.font = EmptyPrivateTabsViewUX.DescriptionFont label.textAlignment = NSTextAlignment.center label.numberOfLines = 0 label.preferredMaxLayoutWidth = EmptyPrivateTabsViewUX.MaxDescriptionWidth return label }() fileprivate var learnMoreButton: UIButton = { let button = UIButton(type: .system) button.setTitle( NSLocalizedString("Learn More", tableName: "PrivateBrowsing", comment: "Text button displayed when there are no tabs open while in private mode"), for: UIControlState()) button.setTitleColor(UIConstants.PrivateModeTextHighlightColor, for: UIControlState()) button.titleLabel?.font = EmptyPrivateTabsViewUX.LearnMoreFont return button }() fileprivate var iconImageView: UIImageView = { let imageView = UIImageView(image: UIImage(named: "largePrivateMask")) return imageView }() override init(frame: CGRect) { super.init(frame: frame) titleLabel.text = NSLocalizedString("Private Browsing", tableName: "PrivateBrowsing", comment: "Title displayed for when there are no open tabs while in private mode") descriptionLabel.text = NSLocalizedString("Firefox won’t remember any of your history or cookies, but new bookmarks will be saved.", tableName: "PrivateBrowsing", comment: "Description text displayed when there are no open tabs while in private mode") addSubview(titleLabel) addSubview(descriptionLabel) addSubview(iconImageView) addSubview(learnMoreButton) titleLabel.snp.makeConstraints { make in make.center.equalTo(self) } iconImageView.snp.makeConstraints { make in make.bottom.equalTo(titleLabel.snp.top).offset(-EmptyPrivateTabsViewUX.TextMargin) make.centerX.equalTo(self) } descriptionLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(EmptyPrivateTabsViewUX.TextMargin) make.centerX.equalTo(self) } learnMoreButton.snp.makeConstraints { (make) -> Void in make.top.equalTo(descriptionLabel.snp.bottom).offset(EmptyPrivateTabsViewUX.LearnMoreMargin).priority(10) make.bottom.lessThanOrEqualTo(self).offset(-EmptyPrivateTabsViewUX.MinBottomMargin).priority(1000) make.centerX.equalTo(self) } } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension TabTrayController: TabPeekDelegate { func tabPeekDidAddBookmark(_ tab: Tab) { delegate?.tabTrayDidAddBookmark(tab) } func tabPeekDidAddToReadingList(_ tab: Tab) -> ReadingListClientRecord? { return delegate?.tabTrayDidAddToReadingList(tab) } func tabPeekDidCloseTab(_ tab: Tab) { if let index = self.tabDataSource.tabs.index(of: tab), let cell = self.collectionView?.cellForItem(at: IndexPath(item: index, section: 0)) as? TabCell { cell.close() } } func tabPeekRequestsPresentationOf(_ viewController: UIViewController) { delegate?.tabTrayRequestsPresentationOf(viewController) } } extension TabTrayController: UIViewControllerPreviewingDelegate { func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { guard let collectionView = collectionView else { return nil } let convertedLocation = self.view.convert(location, to: collectionView) guard let indexPath = collectionView.indexPathForItem(at: convertedLocation), let cell = collectionView.cellForItem(at: indexPath) else { return nil } let tab = tabDataSource.tabs[indexPath.row] let tabVC = TabPeekViewController(tab: tab, delegate: self) if let browserProfile = profile as? BrowserProfile { tabVC.setState(withProfile: browserProfile, clientPickerDelegate: self) } previewingContext.sourceRect = self.view.convert(cell.frame, from: collectionView) return tabVC } func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) { guard let tpvc = viewControllerToCommit as? TabPeekViewController else { return } tabManager.selectTab(tpvc.tab) navigationController?.popViewController(animated: true) delegate?.tabTrayDidDismiss(self) } } extension TabTrayController: ClientPickerViewControllerDelegate { func clientPickerViewController(_ clientPickerViewController: ClientPickerViewController, didPickClients clients: [RemoteClient]) { if let item = clientPickerViewController.shareItem { _ = self.profile.sendItems([item], toClients: clients) } clientPickerViewController.dismiss(animated: true, completion: nil) } func clientPickerViewControllerDidCancel(_ clientPickerViewController: ClientPickerViewController) { clientPickerViewController.dismiss(animated: true, completion: nil) } } extension TabTrayController: UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate { // Returning None here makes sure that the Popover is actually presented as a Popover and // not as a full-screen modal, which is the default on compact device classes. func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { return UIModalPresentationStyle.none } } // MARK: - Toolbar class TrayToolbar: UIView { fileprivate let toolbarButtonSize = CGSize(width: 44, height: 44) lazy var addTabButton: UIButton = { let button = UIButton() button.setImage(UIImage.templateImageNamed("nav-add"), for: .normal) button.accessibilityLabel = NSLocalizedString("Add Tab", comment: "Accessibility label for the Add Tab button in the Tab Tray.") button.accessibilityIdentifier = "TabTrayController.addTabButton" return button }() lazy var deleteButton: UIButton = { let button = UIButton() button.setImage(UIImage.templateImageNamed("action_delete"), for: .normal) button.accessibilityLabel = Strings.TabTrayDeleteMenuButtonAccessibilityLabel button.accessibilityIdentifier = "TabTrayController.removeTabsButton" return button }() lazy var maskButton: PrivateModeButton = PrivateModeButton() fileprivate let sideOffset: CGFloat = 32 fileprivate override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white addSubview(addTabButton) var buttonToCenter: UIButton? addSubview(deleteButton) buttonToCenter = deleteButton maskButton.accessibilityIdentifier = "TabTrayController.maskButton" buttonToCenter?.snp.makeConstraints { make in make.centerX.equalTo(self) make.top.equalTo(self) make.size.equalTo(toolbarButtonSize) } addTabButton.snp.makeConstraints { make in make.top.equalTo(self) make.right.equalTo(self).offset(-sideOffset) make.size.equalTo(toolbarButtonSize) } addSubview(maskButton) maskButton.snp.makeConstraints { make in make.top.equalTo(self) make.left.equalTo(self).offset(sideOffset) make.size.equalTo(toolbarButtonSize) } styleToolbar(false) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } fileprivate func styleToolbar(_ isPrivate: Bool) { addTabButton.tintColor = isPrivate ? UIColor(rgb: 0xf9f9fa) : UIColor(rgb: 0x272727) deleteButton.tintColor = isPrivate ? UIColor(rgb: 0xf9f9fa) : UIColor(rgb: 0x272727) backgroundColor = isPrivate ? UIConstants.PrivateModeToolbarTintColor : UIColor(rgb: 0xf9f9fa) maskButton.styleForMode(privateMode: isPrivate) } }