Dactyloidae/mobile/ios/Client/Frontend/Browser/TabTrayController.swift
2026-06-26 21:04:09 -07:00

1061 lines
43 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 wont 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)
}
}