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

560 lines
22 KiB
Swift

/* 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<IndexPath>
let inserts: Set<IndexPath>
let deletes: Set<IndexPath>
init(reloadArr: [IndexPath], insertArr: [IndexPath], deleteArr: [IndexPath]) {
reloads = Set(reloadArr)
inserts = Set(insertArr)
deletes = Set(deleteArr)
}
var all: [Set<IndexPath>] {
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()
}
}