chromium/ios/chrome/browser/ui/tab_switcher/tab_strip/ui/tab_strip_view_controller.swift

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import UIKit
import ios_chrome_browser_shared_ui_util_util_swift
import ios_chrome_browser_ui_tab_switcher_tab_strip_ui_swift_constants

/// View Controller displaying the TabStrip.
@objcMembers
class TabStripViewController: UIViewController,
  TabStripConsumer, TabStripNewTabButtonDelegate, TabStripGroupCellDelegate, TabStripTabCellDelegate
{

  // The enum used by the data source to manage the sections.
  enum Section: Int {
    case tabs
  }

  private let layout: TabStripLayout
  // The CollectionView used to display the items.
  private let collectionView: UICollectionView
  // The DataSource for this collection view.
  private lazy var dataSource = createDataSource(
    tabCellRegistration: tabCellRegistration, groupCellRegistration: groupCellRegistration)
  private lazy var tabCellRegistration = createTabCellRegistration()
  private lazy var groupCellRegistration = createGroupCellRegistration()

  // Associates data to `dataSource` items, to reconfigure cells.
  private let itemData = NSMutableDictionary()

  // The New tab button.
  private let newTabButton: TabStripNewTabButton = TabStripNewTabButton()

  // Static decoration views that border the collection view. They are
  // visible when the selected cell reaches an edge of the collection view and
  // if the collection view can be scrolled.
  private let leadingStaticSeparator = TabStripDecorationView()
  private let trailingStaticSeparator = TabStripDecorationView()

  // Latest dragged item. This property is set when the item
  // is long pressed which does not always result in a drag action.
  private var draggedItemIdentifier: TabStripItemIdentifier?

  // The item currently selected in the tab strip.
  // The collection view appears to sometimes forget what item is selected,
  // so it is better to store this information here rather than directly using
  // `collectionView.selectItem:` and `collectionView.deselectItem:`.
  // `self.ensureSelectedItemIsSelected()` is used to ensure the value of
  // `collectionView.indexPathsForSelectedItems` remains consistent with `selectedItem`.
  private var selectedItem: TabSwitcherItem? {
    didSet { self.ensureSelectedItemIsSelected() }
  }

  /// `true` if the dragged tab moved to a new index.
  private var dragEndAtNewIndex: Bool = false

  /// `true` if a drop animation is in progress.
  private var dropAnimationInProgress: Bool = false

  /// Targeted scroll offset, used on iOS 16 only.
  /// On iOS 16, the scroll animation after opening a new tab is delayed.
  /// This variable ensures that the most recent scroll event is processed.
  private var targetedScrollOffsetiOS16: CGFloat = 0

  /// `true` if the user is in incognito.
  public var isIncognito: Bool = false

  /// A dictionary that maps each tab item identifier to its index,
  /// either in the set of ungrouped tabs or in its group.
  private var tabIndices: [TabStripItemIdentifier: Int] = [:]
  /// A dictionary that maps each group item identifier to the number of tabs it contains.
  private var numberOfTabsPerGroup: [TabStripItemIdentifier: Int] = [:]
  /// The number of tabs that are not part of any group.
  private var numberOfUngroupedTabs = 0

  /// Handles model updates.
  public weak var mutator: TabStripMutator?
  /// Handles drag and drop interactions.
  public weak var dragDropHandler: TabCollectionDragDropHandler?
  /// Provides context menu for tab strip items.
  public weak var contextMenuProvider: TabStripContextMenuProvider?

  /// Handler for tab group confirmation commands.
  public weak var tabGroupConfirmationHandler: TabGroupConfirmationCommands?

  /// The LayoutGuideCenter.
  @objc public var layoutGuideCenter: LayoutGuideCenter? {
    didSet {
      layoutGuideCenter?.reference(view: newTabButton.layoutGuideView, under: kNewTabButtonGuide)
    }
  }

  init() {
    layout = TabStripLayout()
    collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    super.init(nibName: nil, bundle: nil)

    layout.dataSource = dataSource
    layout.leadingStaticSeparator = leadingStaticSeparator
    layout.trailingStaticSeparator = trailingStaticSeparator
    layout.newTabButton = newTabButton

    collectionView.delegate = self
    collectionView.dragDelegate = self
    collectionView.dropDelegate = self
    collectionView.showsHorizontalScrollIndicator = false
    collectionView.allowsMultipleSelection = false
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) is not supported")
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = TabStripHelper.backgroundColor

    // Don't clip to bound the collection view to allow the shadow of the long press to be displayed fully.
    // The trailing placeholder will ensure that the cells aren't displayed out of the bounds.
    collectionView.clipsToBounds = false
    collectionView.translatesAutoresizingMaskIntoConstraints = false
    collectionView.backgroundColor = .clear
    view.addSubview(collectionView)

    let trailingPlaceholder = UIView()
    trailingPlaceholder.translatesAutoresizingMaskIntoConstraints = false
    trailingPlaceholder.backgroundColor = view.backgroundColor
    // Add the placeholder above the collection view but below the other elements.
    view.insertSubview(trailingPlaceholder, aboveSubview: collectionView)

    // Mirror the layer.
    trailingStaticSeparator.transform = CGAffineTransformMakeScale(-1, 1)
    view.addSubview(leadingStaticSeparator)
    view.addSubview(trailingStaticSeparator)

    newTabButton.delegate = self
    newTabButton.isIncognito = isIncognito
    view.addSubview(newTabButton)

    if TabStripFeaturesUtils.isModernTabStripNewTabButtonDynamic {
      NSLayoutConstraint.activate([
        collectionView.trailingAnchor.constraint(
          equalTo: view.trailingAnchor, constant: -TabStripConstants.NewTabButton.width),
        newTabButton.leadingAnchor.constraint(
          greaterThanOrEqualTo: view.leadingAnchor),
        newTabButton.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
      ])
    } else {
      NSLayoutConstraint.activate([
        newTabButton.leadingAnchor.constraint(
          equalTo: collectionView.trailingAnchor),
        newTabButton.trailingAnchor.constraint(equalTo: view.trailingAnchor),
      ])
    }

    NSLayoutConstraint.activate(
      [
        /// `trailingPlaceholder` constraints.
        trailingPlaceholder.leadingAnchor.constraint(equalTo: collectionView.trailingAnchor),
        trailingPlaceholder.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        trailingPlaceholder.topAnchor.constraint(equalTo: view.topAnchor),
        trailingPlaceholder.bottomAnchor.constraint(equalTo: view.bottomAnchor),

        /// `collectionView` constraints.
        collectionView.leadingAnchor.constraint(
          equalTo: view.leadingAnchor),
        collectionView.topAnchor.constraint(
          equalTo: view.topAnchor),
        collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

        /// `newTabButton` constraints.
        newTabButton.leadingAnchor.constraint(
          greaterThanOrEqualTo: view.leadingAnchor),
        newTabButton.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
        newTabButton.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        newTabButton.topAnchor.constraint(equalTo: view.topAnchor),
        newTabButton.widthAnchor.constraint(equalToConstant: TabStripConstants.NewTabButton.width),

        /// `leadingStaticSeparator` constraints.
        leadingStaticSeparator.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
        leadingStaticSeparator.bottomAnchor.constraint(
          equalTo: collectionView.bottomAnchor,
          constant: -TabStripConstants.StaticSeparator.bottomInset),
        /// `trailingStaticSeparator` constraints.
        trailingStaticSeparator.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
        trailingStaticSeparator.bottomAnchor.constraint(
          equalTo: collectionView.bottomAnchor,
          constant: -TabStripConstants.StaticSeparator.bottomInset),
      ])
  }

  override func viewWillTransition(
    to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator
  ) {
    // Dismisses the confirmation dialog for tab group if it's displayed.
    tabGroupConfirmationHandler?.dismissTabGroupConfirmation()

    super.viewWillTransition(to: size, with: coordinator)
    weak var weakSelf = self
    coordinator.animate(alongsideTransition: nil) { _ in
      // The tab cell size must be updated after the transition completes.
      // Otherwise the collection view width won't be updated.
      weakSelf?.layout.calculateTabCellSize()
      weakSelf?.layout.invalidateLayout()
    }
  }

  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.ensureSelectedItemIsSelected()
    // In case the device orientation was updated while the tab strip was not
    // visible, recalculate the item size.
    layout.needsSizeUpdate = true
    NotificationCenter.default.addObserver(
      self, selector: #selector(voiceOverChanged),
      name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil)
  }

  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    layout.needsSizeUpdate = false
  }

  override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    NotificationCenter.default.removeObserver(
      self, name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil)
  }

  // MARK: - TabStripConsumer

  func populate(
    items itemIdentifiers: [TabStripItemIdentifier]?, selectedItem: TabSwitcherItem?,
    itemData: [TabStripItemIdentifier: TabStripItemData],
    itemParents: [TabStripItemIdentifier: TabGroupItem]
  ) {
    guard let itemIdentifiers = itemIdentifiers else {
      return
    }
    var snapshot = NSDiffableDataSourceSectionSnapshot<TabStripItemIdentifier>()
    for itemIdentifier in itemIdentifiers {
      switch itemIdentifier.item {
      case .group(let tabGroupItem):
        snapshot.append([itemIdentifier])
        if !tabGroupItem.collapsed {
          snapshot.expand([itemIdentifier])
        }
      case .tab(_):
        snapshot.append([itemIdentifier], to: TabStripItemIdentifier(itemParents[itemIdentifier]))
      }
    }
    self.itemData.setDictionary(itemData)

    // TODO(crbug.com/325415449): Update this when #unavailable is rocognized by
    // the formatter.
    if #available(iOS 17.0, *) {
    } else {
      layout.cellAnimatediOS16 = true
    }

    // To make the animation smoother, try to select the item if it's already
    // present in the collection view.
    selectItem(selectedItem)
    reconfigureItems(itemIdentifiers)
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot,
      animatingDifferences: !UIAccessibility.isReduceMotionEnabled,
      numberOfVisibleItemsChanged: true)
    selectItem(selectedItem)
  }

  func selectItem(_ item: TabSwitcherItem?) {
    self.selectedItem = item
  }

  func reconfigureItems(_ items: [TabStripItemIdentifier]) {
    var snapshot = dataSource.snapshot()
    let itemsInSnapshot = Set(snapshot.itemIdentifiers)
    let itemsToReconfigure = Set(items).intersection(itemsInSnapshot)
    snapshot.reconfigureItems(Array(itemsToReconfigure))
    dataSource.apply(snapshot)
  }

  func moveItem(
    _ itemToMoveIdentifier: TabStripItemIdentifier,
    beforeItem destinationItemIdentifier: TabStripItemIdentifier?
  ) {
    var snapshot = dataSource.snapshot(for: .tabs)
    let itemIsExpanded = snapshot.isExpanded(itemToMoveIdentifier)
    let childItems = snapshot.snapshot(of: itemToMoveIdentifier).items
    snapshot.delete([itemToMoveIdentifier])
    if let destinationItemIdentifier = destinationItemIdentifier {
      snapshot.insert([itemToMoveIdentifier], before: destinationItemIdentifier)
    } else {
      snapshot.append([itemToMoveIdentifier])
    }
    if itemIsExpanded {
      snapshot.expand([itemToMoveIdentifier])
    }
    if !childItems.isEmpty {
      snapshot.append(childItems, to: itemToMoveIdentifier)
    }
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot, animatingDifferences: true)
    layout.invalidateLayout()
  }

  func moveItem(
    _ itemToMoveIdentifier: TabStripItemIdentifier,
    afterItem destinationItemIdentifier: TabStripItemIdentifier?
  ) {
    var snapshot = dataSource.snapshot(for: .tabs)
    let itemIsExpanded = snapshot.isExpanded(itemToMoveIdentifier)
    let childItems = snapshot.snapshot(of: itemToMoveIdentifier).items
    snapshot.delete([itemToMoveIdentifier])
    if let destinationItemIdentifier = destinationItemIdentifier {
      snapshot.insert([itemToMoveIdentifier], after: destinationItemIdentifier)
    } else if let firstItemIdentifier = snapshot.items.first {
      snapshot.insert([itemToMoveIdentifier], before: firstItemIdentifier)
    } else {
      snapshot.append([itemToMoveIdentifier])
    }
    if itemIsExpanded {
      snapshot.expand([itemToMoveIdentifier])
    }
    if !childItems.isEmpty {
      snapshot.append(childItems, to: itemToMoveIdentifier)
    }
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot, animatingDifferences: true)
    layout.invalidateLayout()
  }

  func moveItem(
    _ itemToMoveIdentifier: TabStripItemIdentifier, insideGroup parentItem: TabGroupItem
  ) {
    var snapshot = dataSource.snapshot(for: .tabs)
    snapshot.delete([itemToMoveIdentifier])
    snapshot.append([itemToMoveIdentifier], to: TabStripItemIdentifier(parentItem))
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot, animatingDifferences: true)
    layout.invalidateLayout()
  }

  func insertItems(
    _ itemIdentifiers: [TabStripItemIdentifier],
    beforeItem destinationItemIdentifier: TabStripItemIdentifier?
  ) {
    var snapshot = dataSource.snapshot(for: .tabs)
    if let destinationItemIdentifier = destinationItemIdentifier {
      snapshot.insert(itemIdentifiers, before: destinationItemIdentifier)
    } else {
      snapshot.append(itemIdentifiers)
    }
    snapshot.expand(itemIdentifiers.lazy.filter { $0.itemType == .group })
    let insertedLast = snapshot.items.last == itemIdentifiers.last
    insertItemsUsingSnapshot(snapshot, insertedLast: insertedLast)
  }

  func insertItems(
    _ itemIdentifiers: [TabStripItemIdentifier],
    afterItem destinationItemIdentifier: TabStripItemIdentifier?
  ) {
    var snapshot = dataSource.snapshot(for: .tabs)
    if let destinationItemIdentifier = destinationItemIdentifier {
      snapshot.insert(itemIdentifiers, after: destinationItemIdentifier)
    } else if let firstItemIdentifier = snapshot.items.first {
      snapshot.insert(itemIdentifiers, before: firstItemIdentifier)
    } else {
      snapshot.append(itemIdentifiers)
    }
    snapshot.expand(itemIdentifiers.lazy.filter { $0.itemType == .group })
    let insertedLast = snapshot.items.last == itemIdentifiers.last
    insertItemsUsingSnapshot(snapshot, insertedLast: insertedLast)
  }

  func insertItems(
    _ itemIdentifiers: [TabStripItemIdentifier],
    insideGroup parentItem: TabGroupItem
  ) {
    var snapshot = dataSource.snapshot(for: .tabs)
    snapshot.append(itemIdentifiers, to: TabStripItemIdentifier(parentItem))
    let insertedLast = snapshot.items.last == itemIdentifiers.last
    insertItemsUsingSnapshot(snapshot, insertedLast: insertedLast)
  }

  func removeItems(_ items: [TabStripItemIdentifier]?) {
    guard let items = items else { return }

    var snapshot = dataSource.snapshot(for: .tabs)
    snapshot.delete(items)
    itemData.removeObjects(forKeys: items)
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot,
      animatingDifferences: !UIAccessibility.isReduceMotionEnabled,
      numberOfVisibleItemsChanged: true)
  }

  func replaceItem(_ oldItem: TabSwitcherItem?, withItem newItem: TabSwitcherItem?) {
    guard let oldItem = TabStripItemIdentifier.tabIdentifier(oldItem),
      let newItem = TabStripItemIdentifier.tabIdentifier(newItem)
    else {
      return
    }

    var snapshot = dataSource.snapshot(for: .tabs)
    snapshot.insert([newItem], before: oldItem)
    snapshot.delete([oldItem])
    itemData.removeObject(forKey: oldItem)
    applySnapshot(dataSource: dataSource, snapshot: snapshot)
  }

  func updateItemData(
    _ updatedItemData: [TabStripItemIdentifier: TabStripItemData], reconfigureItems: Bool = false
  ) {
    itemData.addEntries(from: updatedItemData)
    if reconfigureItems {
      self.reconfigureItems(Array(updatedItemData.keys))
    }
  }

  func collapseGroup(_ group: TabGroupItem) {
    var snapshot = dataSource.snapshot(for: .tabs)
    snapshot.collapse([TabStripItemIdentifier(group)])
    layout.prepareForItemsCollapsing()
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot, animatingDifferences: true,
      numberOfVisibleItemsChanged: true)
    layout.finalizeItemsCollapsing()
  }

  func expandGroup(_ group: TabGroupItem) {
    var snapshot = dataSource.snapshot(for: .tabs)
    snapshot.expand([TabStripItemIdentifier(group)])
    layout.prepareForItemsExpanding()
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot, animatingDifferences: true,
      numberOfVisibleItemsChanged: true)
    layout.finalizeItemsExpanding()
  }

  // MARK: - Public

  @objc func setNewTabButtonOnTabStripIPHHighlighted(_ iphHighlighted: Bool) {
    newTabButton.IPHHighlighted = iphHighlighted
  }

  // MARK: - TabStripGroupCellDelegate

  func collapseOrExpandTapped(for cell: TabStripGroupCell?) {
    guard let cell = cell,
      let indexPath = collectionView.indexPath(for: cell)
    else { return }
    collapseOrExpandGroup(at: indexPath)
  }

  // MARK: - TabStripTabCellDelegate

  func closeButtonTapped(for cell: TabStripTabCell?) {
    guard let cell = cell,
      let indexPath = collectionView.indexPath(for: cell),
      let item = dataSource.itemIdentifier(for: indexPath)?.tabSwitcherItem
    else {
      return
    }
    mutator?.close(item)
  }

  // MARK: - UIScrollViewDelegate

  func scrollViewDidEndDragging(
    _ scrollView: UIScrollView,
    willDecelerate decelerate: Bool
  ) {
    layout.cellAnimatediOS16 = false
    UserMetricsUtils.recordAction("MobileTabStripScrollDidEnd")
  }

  // MARK: - Private

  /// Collapses or expands the group at `indexPath`.
  func collapseOrExpandGroup(at indexPath: IndexPath) {
    guard let tabGroupItem = dataSource.itemIdentifier(for: indexPath)?.tabGroupItem else {
      return
    }
    if tabGroupItem.collapsed {
      mutator?.expandGroup(tabGroupItem)
    } else {
      mutator?.collapseGroup(tabGroupItem)
    }
  }

  /// Applies `snapshot` to `dataSource` and updates the collection view layout.
  private func applySnapshot(
    dataSource: UICollectionViewDiffableDataSource<Section, TabStripItemIdentifier>,
    snapshot: NSDiffableDataSourceSectionSnapshot<TabStripItemIdentifier>,
    animatingDifferences: Bool = false,
    numberOfVisibleItemsChanged: Bool = false
  ) {
    if #available(iOS 17.0, *) {
      if numberOfVisibleItemsChanged {
        layout.needsSizeUpdate = true
      }
    } else {
      /// On iOS 16, the layout fails to invalidate when it should.
      /// To fix this, we set `needsSizeUpdate` to `true` whenever a snapshot
      /// is applied.
      layout.needsSizeUpdate = true
    }

    dataSource.apply(snapshot, to: .tabs, animatingDifferences: animatingDifferences)
    layout.needsSizeUpdate = false

    ensureSelectedItemIsSelected()
    updateVisibleCellIdentifiers()
    if UIAccessibility.isVoiceOverRunning {
      updateTabIndices()
      updateVisibleCellTabIndices()
    }
  }

  /// Creates and returns the data source for the collection view.
  private func createDataSource(
    tabCellRegistration: UICollectionView.CellRegistration<TabStripTabCell, TabStripItemIdentifier>,
    groupCellRegistration: UICollectionView.CellRegistration<
      TabStripGroupCell, TabStripItemIdentifier
    >
  ) -> UICollectionViewDiffableDataSource<Section, TabStripItemIdentifier> {
    let dataSource = UICollectionViewDiffableDataSource<Section, TabStripItemIdentifier>(
      collectionView: collectionView
    ) {
      (
        collectionView: UICollectionView, indexPath: IndexPath,
        itemIdentifier: TabStripItemIdentifier
      )
        -> UICollectionViewCell? in
      return Self.getCell(
        collectionView: collectionView, indexPath: indexPath, itemIdentifier: itemIdentifier,
        tabCellRegistration: tabCellRegistration, groupCellRegistration: groupCellRegistration)
    }

    var snapshot = dataSource.snapshot()
    snapshot.appendSections([.tabs])
    dataSource.apply(snapshot)

    return dataSource
  }

  /// Creates the registrations of tab cells used in the collection view.
  private func createTabCellRegistration()
    -> UICollectionView.CellRegistration<TabStripTabCell, TabStripItemIdentifier>
  {
    let tabCellRegistration = UICollectionView.CellRegistration<
      TabStripTabCell, TabStripItemIdentifier
    > {
      (cell, indexPath, itemIdentifier) in
      guard let item = itemIdentifier.tabSwitcherItem else { return }
      let itemData = self.itemData[itemIdentifier] as? TabStripItemData
      cell.title = item.title
      cell.groupStrokeColor = itemData?.groupStrokeColor
      cell.isFirstTabInGroup = itemData?.isFirstTabInGroup == true
      cell.isLastTabInGroup = itemData?.isLastTabInGroup == true
      cell.loading = item.showsActivity
      cell.delegate = self
      cell.accessibilityIdentifier = self.tabTripTabCellAccessibilityIdentifier(
        index: indexPath.item)
      cell.item = item
      if UIAccessibility.isVoiceOverRunning {
        cell.tabIndex = self.tabIndices[itemIdentifier] ?? 0
        let snapshot = self.dataSource.snapshot(for: .tabs)
        if let parentGroup = snapshot.parent(of: itemIdentifier) {
          cell.numberOfTabs = self.numberOfTabsPerGroup[parentGroup] ?? 0
        } else {
          cell.numberOfTabs = self.numberOfUngroupedTabs
        }
      }

      item.fetchFavicon { (item: TabSwitcherItem?, image: UIImage?) -> Void in
        if let item = item, item == cell.item {
          cell.setFaviconImage(image)
        }
      }
    }

    // UICollectionViewDropPlaceholder uses a TabStripTabCell and needs the class to be
    // registered.
    collectionView.register(
      TabStripTabCell.self,
      forCellWithReuseIdentifier: TabStripConstants.CollectionView.tabStripTabCellReuseIdentifier)

    return tabCellRegistration
  }

  /// Creates the registrations of group cells used in the collection view.
  private func createGroupCellRegistration()
    -> UICollectionView.CellRegistration<
      TabStripGroupCell, TabStripItemIdentifier
    >
  {
    return UICollectionView.CellRegistration<
      TabStripGroupCell, TabStripItemIdentifier
    > {
      (cell, indexPath, itemIdentifier) in
      guard let item = itemIdentifier.tabGroupItem else { return }
      let itemData = self.itemData[itemIdentifier] as? TabStripItemData
      cell.title = item.title
      cell.titleContainerBackgroundColor = item.groupColor
      cell.titleTextColor = item.foregroundColor
      cell.collapsed = item.collapsed
      cell.delegate = self
      cell.groupStrokeColor = itemData?.groupStrokeColor
      cell.accessibilityIdentifier = self.tabTripGroupCellAccessibilityIdentifier(
        index: indexPath.item)
    }
  }

  /// Retuns the cell to be used in the collection view.
  static private func getCell(
    collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: TabStripItemIdentifier,
    tabCellRegistration: UICollectionView.CellRegistration<TabStripTabCell, TabStripItemIdentifier>,
    groupCellRegistration: UICollectionView.CellRegistration<
      TabStripGroupCell, TabStripItemIdentifier
    >
  ) -> UICollectionViewCell? {
    switch itemIdentifier.item {
    case .tab(_):
      return collectionView.dequeueConfiguredReusableCell(
        using: tabCellRegistration,
        for: indexPath,
        item: itemIdentifier)
    case .group(_):
      return collectionView.dequeueConfiguredReusableCell(
        using: groupCellRegistration,
        for: indexPath,
        item: itemIdentifier)
    }
  }

  // Updates visible cells identifier, following a reorg of cells.
  func updateVisibleCellIdentifiers() {
    for indexPath in collectionView.indexPathsForVisibleItems {
      switch collectionView.cellForItem(at: indexPath) {
      case let tabCell as TabStripTabCell:
        tabCell.accessibilityIdentifier = tabTripTabCellAccessibilityIdentifier(
          index: indexPath.item)
      case let groupCell as TabStripGroupCell:
        groupCell.accessibilityIdentifier = tabTripGroupCellAccessibilityIdentifier(
          index: indexPath.item)
      default:
        continue
      }
    }
  }

  // Updates `tabIndex` and `numberOfTabs` for visible tab cells, according to `tabIndices`,
  // `numberOfTabsPerGroup` and `numberOfUngroupedTabs`, following a reorg of cells.
  func updateVisibleCellTabIndices() {
    let snapshot = dataSource.snapshot(for: .tabs)
    for indexPath in collectionView.indexPathsForVisibleItems {
      if let tabCell = collectionView.cellForItem(at: indexPath) as? TabStripTabCell,
        let itemIdentifier = dataSource.itemIdentifier(for: indexPath),
        let tabIndex = tabIndices[itemIdentifier]
      {
        tabCell.tabIndex = tabIndex
        if let parentGroup = snapshot.parent(of: itemIdentifier) {
          tabCell.numberOfTabs = numberOfTabsPerGroup[parentGroup] ?? 0
        } else {
          tabCell.numberOfTabs = numberOfUngroupedTabs
        }
      }
    }
  }

  // Updates `tabIndices`, `numberOfUngroupedTabs` and `numberOfTabsPerGroup` according to `dataSource`.
  func updateTabIndices() {
    let snapshot = dataSource.snapshot(for: .tabs)
    var currentGroupItemIdentifier: TabStripItemIdentifier? = nil
    // Updates `tabIndices` and counts grouped and ungrouped tabs.
    tabIndices = [:]
    numberOfTabsPerGroup = [:]
    numberOfUngroupedTabs = 0
    for itemIdentifier in snapshot.items {
      if snapshot.level(of: itemIdentifier) == 0 {
        currentGroupItemIdentifier = nil
      }
      switch itemIdentifier.item {
      case .tab(_):
        if let currentGroupItemIdentifier = currentGroupItemIdentifier {
          numberOfTabsPerGroup[currentGroupItemIdentifier, default: 0] += 1
          tabIndices[itemIdentifier] = numberOfTabsPerGroup[currentGroupItemIdentifier]
        } else {
          numberOfUngroupedTabs += 1
          tabIndices[itemIdentifier] = numberOfUngroupedTabs
        }
      case .group(_):
        currentGroupItemIdentifier = itemIdentifier
      }
    }
  }

  // Returns the accessibility identifier to set on a TabStripTabCell when
  // positioned at the given index.
  func tabTripTabCellAccessibilityIdentifier(index: Int) -> String {
    return "\(TabStripConstants.CollectionView.tabStripTabCellPrefixIdentifier)\(index)"
  }

  // Returns the accessibility identifier to set on a TabStripGroupCell when
  // positioned at the given index.
  func tabTripGroupCellAccessibilityIdentifier(index: Int) -> String {
    return "\(TabStripConstants.CollectionView.tabStripGroupCellPrefixIdentifier)\(index)"
  }

  /// Scrolls the collection view to the given horizontal `offset`.
  func scrollToContentOffset(_ offset: CGFloat) {
    // TODO(crbug.com/325415449): Update this when #unavailable is recognized by
    // the formatter.
    if #available(iOS 17.0, *) {
    } else {
      if offset != targetedScrollOffsetiOS16 { return }
    }
    self.collectionView.setContentOffset(
      CGPoint(x: offset, y: 0),
      animated: !UIAccessibility.isReduceMotionEnabled)
  }

  /// Ensures `collectionView.indexPathsForSelectedItems` is consistent with
  /// `self.selectedItem`.
  func ensureSelectedItemIsSelected() {
    let expectedIndexPathForSelectedItem =
      TabStripItemIdentifier(selectedItem).flatMap { dataSource.indexPath(for: $0) }
    let observedIndexPathForSelectedItem = collectionView.indexPathsForSelectedItems?.first

    // If the observed selected indexPath doesn't match the expected selected
    // indexPath, update the observed selected item.
    if expectedIndexPathForSelectedItem != observedIndexPathForSelectedItem {
      collectionView.selectItem(
        at: expectedIndexPathForSelectedItem, animated: false, scrollPosition: [])
    }

    /// Invalidate the layout to correctly recalculate the frame of the `selected` cell.
    layout.selectedItem = selectedItem
    layout.invalidateLayout()
  }

  /// Inserts items by applying `snapshot`, updates `numberOfTabs` and optionally scrolls to the end of the collection view if `insertedLast` is true.
  /// To use this method, create a snapshot from `dataSource`, insert items in the snapshot and pass it as the first argument `snapshot`.
  func insertItemsUsingSnapshot(
    _ snapshot: NSDiffableDataSourceSectionSnapshot<TabStripItemIdentifier>, insertedLast: Bool
  ) {
    applySnapshot(
      dataSource: dataSource, snapshot: snapshot,
      animatingDifferences: !UIAccessibility.isReduceMotionEnabled,
      numberOfVisibleItemsChanged: true)

    if insertedLast {
      let offset = collectionView.contentSize.width - collectionView.frame.width
      if offset > 0 {
        if #available(iOS 17.0, *) {
          scrollToContentOffset(offset)
        } else {
          // On iOS 16, when the scroll animation and the insert animation
          // occur simultaneously, the resulting animation lacks of
          // smoothness.
          weak var weakSelf = self
          targetedScrollOffsetiOS16 = offset
          DispatchQueue.main.asyncAfter(
            deadline: .now() + TabStripConstants.CollectionView.scrollDelayAfterInsert
          ) {
            weakSelf?.scrollToContentOffset(offset)
          }
        }
      } else {
        layout.cellAnimatediOS16 = false
      }
    }
  }

  // Called when voice over is activated.
  @objc func voiceOverChanged() {
    guard UIAccessibility.isVoiceOverRunning else { return }
    self.updateTabIndices()
    self.updateVisibleCellTabIndices()
  }

  // MARK: - TabStripNewTabButtonDelegate

  @objc func newTabButtonTapped() {
    UserMetricsUtils.recordAction("MobileTabSwitched")
    UserMetricsUtils.recordAction("MobileTabStripNewTab")
    UserMetricsUtils.recordAction("MobileTabNewTab")

    mutator?.addNewItem()
  }

}

// MARK: - UICollectionViewDelegateFlowLayout

extension TabStripViewController: UICollectionViewDelegateFlowLayout {

  func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath)
    -> Bool
  {
    switch dataSource.itemIdentifier(for: indexPath)?.item {
    case .tab(_):
      // Only tabs are selectable since being selected is equivalent to having
      // the associated WebState being active.
      return true
    default:
      return false
    }
  }

  func collectionView(
    _ collectionView: UICollectionView, performPrimaryActionForItemAt indexPath: IndexPath
  ) {
    guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return }
    switch itemIdentifier.item {
    case .tab(let tabSwitcherItem):
      mutator?.activate(tabSwitcherItem)
    case .group(_):
      collapseOrExpandGroup(at: indexPath)
    }
  }

  func collectionView(
    _ collectionView: UICollectionView,
    contextMenuConfiguration configuration: UIContextMenuConfiguration,
    highlightPreviewForItemAt indexPath: IndexPath
  ) -> UITargetedPreview? {
    guard let tabStripCell = collectionView.cellForItem(at: indexPath) as? TabStripCell else {
      return nil
    }
    return UITargetedPreview(view: tabStripCell, parameters: tabStripCell.dragPreviewParameters)
  }

  func collectionView(
    _ collectionView: UICollectionView,
    contextMenuConfiguration configuration: UIContextMenuConfiguration,
    dismissalPreviewForItemAt indexPath: IndexPath
  ) -> UITargetedPreview? {
    guard let tabStripCell = collectionView.cellForItem(at: indexPath) as? TabStripCell else {
      return nil
    }
    return UITargetedPreview(view: tabStripCell, parameters: tabStripCell.dragPreviewParameters)
  }

  func collectionView(
    _ collectionView: UICollectionView,
    contextMenuConfigurationForItemAt indexPath: IndexPath,
    point: CGPoint
  ) -> UIContextMenuConfiguration? {
    return self.collectionView(
      collectionView, contextMenuConfigurationForItemsAt: [indexPath], point: point)
  }

  func collectionView(
    _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath],
    point: CGPoint
  ) -> UIContextMenuConfiguration? {
    guard let indexPath = indexPaths.first,
      let itemIdentifier = dataSource.itemIdentifier(for: indexPath),
      let cell = collectionView.cellForItem(at: indexPath)
    else {
      return nil
    }
    switch itemIdentifier.item {
    case .tab(let tabSwitcherItem):
      return contextMenuProvider?.contextMenuConfiguration(
        for: tabSwitcherItem, originView: cell, menuScenario: kMenuScenarioHistogramTabStripEntry)
    case .group(let tabGroupItem):
      return contextMenuProvider?.contextMenuConfiguration(
        for: tabGroupItem, originView: cell, menuScenario: kMenuScenarioHistogramTabStripEntry)
    }
  }

  func collectionView(
    _ collectionView: UICollectionView,
    layout collectionViewLayout: UICollectionViewLayout,
    sizeForItemAt indexPath: IndexPath
  ) -> CGSize {
    switch dataSource.itemIdentifier(for: indexPath)?.item {
    case .tab(let tabSwitcherItem):
      return layout.calculateCellSizeForTabSwitcherItem(tabSwitcherItem)
    case .group(let tabGroupItem):
      return layout.calculateCellSizeForTabGroupItem(tabGroupItem)
    case nil:
      return CGSizeZero
    }
  }

}

extension TabStripViewController: UICollectionViewDragDelegate, UICollectionViewDropDelegate {
  // MARK: - UICollectionViewDragDelegate

  func collectionView(
    _ collectionView: UICollectionView,
    dragSessionIsRestrictedToDraggingApplication session: UIDragSession
  ) -> Bool {
    // Needed to avoid triggering new Chrome window opening when dragging
    // an item close to an edge of the collection view.
    // Dragged item can still be dropped in another Chrome window.
    return true
  }

  func collectionView(
    _ collectionView: UICollectionView,
    dragSessionWillBegin session: UIDragSession
  ) {
    guard let draggedItemIdentifier = draggedItemIdentifier else { return }
    dragEndAtNewIndex = false
    switch draggedItemIdentifier.item {
    case .tab(let tabSwitcherItem):
      dragDropHandler?.dragWillBegin?(for: tabSwitcherItem)
      HistogramUtils.recordHistogram(
        kUmaTabStripViewDragDropTabsEvent, withSample: DragDropTabs.dragBegin.rawValue,
        maxValue: DragDropTabs.maxValue.rawValue)
    case .group(let tabGroupItem):
      dragDropHandler?.dragWillBegin?(for: tabGroupItem)
      HistogramUtils.recordHistogram(
        kUmaTabStripViewDragDropGroupsEvent, withSample: DragDropTabs.dragBegin.rawValue,
        maxValue: DragDropTabs.maxValue.rawValue)
    }
  }

  func collectionView(
    _ collectionView: UICollectionView,
    dragSessionDidEnd session: UIDragSession
  ) {
    var dragEvent =
      dragEndAtNewIndex
      ? DragDropTabs.dragEndAtNewIndex
      : DragDropTabs.dragEndAtSameIndex

    // If a drop animation is in progress and the drag didn't end at a new index,
    // that means the item has been dropped outside of its collection view.
    if dropAnimationInProgress && !dragEndAtNewIndex {
      dragEvent = DragDropTabs.dragEndInOtherCollection
    }

    dragDropHandler?.dragSessionDidEnd?()

    switch draggedItemIdentifier?.item {
    case .tab(_):
      HistogramUtils.recordHistogram(
        kUmaTabStripViewDragDropTabsEvent, withSample: dragEvent.rawValue,
        maxValue: DragDropTabs.maxValue.rawValue)
    case .group(let tabGroupItem):
      HistogramUtils.recordHistogram(
        kUmaTabStripViewDragDropGroupsEvent, withSample: dragEvent.rawValue,
        maxValue: DragDropTabs.maxValue.rawValue)
      if !tabGroupItem.collapsed, let draggedItemIdentifier = draggedItemIdentifier {
        // If the dragged item is a group and the group was expanded before it started being dragged, then expand it back.
        var snapshot = dataSource.snapshot(for: .tabs)
        snapshot.expand([draggedItemIdentifier])
        applySnapshot(
          dataSource: dataSource, snapshot: snapshot, animatingDifferences: true,
          numberOfVisibleItemsChanged: false)
      }
    default:
      break
    }

    // Reset the current dragged item.
    draggedItemIdentifier = nil
  }

  func collectionView(
    _ collectionView: UICollectionView,
    dragPreviewParametersForItemAt indexPath: IndexPath
  ) -> UIDragPreviewParameters? {
    guard let tabStripCell = collectionView.cellForItem(at: indexPath) as? TabStripCell else {
      return nil
    }
    return tabStripCell.dragPreviewParameters
  }

  func collectionView(
    _ collectionView: UICollectionView,
    itemsForBeginning session: UIDragSession,
    at indexPath: IndexPath
  ) -> [UIDragItem] {
    guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else {
      return []
    }
    let dragItem: UIDragItem?
    switch itemIdentifier.item {
    case .tab(let tabSwitcherItem):
      dragItem = dragDropHandler?.dragItem?(for: tabSwitcherItem)
    case .group(let tabGroupItem):
      dragItem = dragDropHandler?.dragItem?(for: tabGroupItem)
    }
    guard let dragItem = dragItem else {
      return []
    }
    draggedItemIdentifier = itemIdentifier
    return [dragItem]
  }

  func collectionView(
    _ collectionView: UICollectionView,
    itemsForAddingTo session: UIDragSession,
    at indexPath: IndexPath,
    point: CGPoint
  ) -> [UIDragItem] {
    // Prevent more items from getting added to the drag session.
    return []
  }

  // MARK: - UICollectionViewDropDelegate

  func collectionView(
    _ collectionView: UICollectionView,
    dropPreviewParametersForItemAt indexPath: IndexPath
  ) -> UIDragPreviewParameters? {
    guard let tabStripCell = collectionView.cellForItem(at: indexPath) as? TabStripCell else {
      return nil
    }
    return tabStripCell.dragPreviewParameters
  }

  func collectionView(
    _ collectionView: UICollectionView,
    dropSessionDidUpdate session: UIDropSession,
    withDestinationIndexPath destinationIndexPath: IndexPath?
  ) -> UICollectionViewDropProposal {
    // Calculating location in view
    let location = session.location(in: collectionView)
    var destinationIndexPath: IndexPath?
    collectionView.performUsingPresentationValues {
      destinationIndexPath = collectionView.indexPathForItem(at: location)
    }
    guard let destinationIndexPath = destinationIndexPath else {
      return UICollectionViewDropProposal(operation: .cancel, intent: .unspecified)
    }
    guard
      let dropOperation: UIDropOperation = dragDropHandler?.dropOperation(
        for: session, to: UInt(destinationIndexPath.item))
    else {
      return UICollectionViewDropProposal(operation: .cancel)
    }
    /// Use `insertIntoDestinationIndexPath` if the dragged item is not from the same
    /// collection view. This prevents having unwanted empty space in the collection view.
    return UICollectionViewDropProposal(
      operation: dropOperation,
      intent: draggedItemIdentifier != nil
        ? .insertAtDestinationIndexPath : .insertIntoDestinationIndexPath)
  }

  func collectionView(
    _ collectionView: UICollectionView,
    performDropWith coordinator: UICollectionViewDropCoordinator
  ) {
    for item in coordinator.items {
      // Append to the end of the collection, unless drop index is specified.
      // The sourceIndexPath is nil if the drop item is not from the same
      // collection view. Set the destinationIndex to reflect the addition of an
      // item.
      let tabsCount = collectionView.numberOfItems(inSection: 0)
      var destinationIndex = item.sourceIndexPath != nil ? (tabsCount - 1) : tabsCount
      if let destinationIndexPath = coordinator.destinationIndexPath {
        destinationIndex = destinationIndexPath.item
      }
      let dropIndexPath = dropIndexPath(item: item, destinationIndex: destinationIndex)
      dragEndAtNewIndex = true

      // Drop synchronously if local object is available.
      if item.dragItem.localObject != nil {
        weak var weakSelf = self
        coordinator.drop(item.dragItem, toItemAt: dropIndexPath).addCompletion {
          _ in
          weakSelf?.dropAnimationInProgress = false
        }
        // The sourceIndexPath is non-nil if the drop item is from this same
        // collection view.
        self.dragDropHandler?.drop(
          item.dragItem, to: UInt(destinationIndex),
          fromSameCollection: (item.sourceIndexPath != nil))
      } else {
        // Drop asynchronously if local object is not available.
        let placeholder: UICollectionViewDropPlaceholder = UICollectionViewDropPlaceholder(
          insertionIndexPath: dropIndexPath,
          reuseIdentifier: TabStripConstants.CollectionView.tabStripTabCellReuseIdentifier)
        placeholder.previewParametersProvider = {
          (placeholderCell: UICollectionViewCell) -> UIDragPreviewParameters? in
          guard let tabStripCell = placeholderCell as? TabStripTabCell else {
            return nil
          }
          return tabStripCell.dragPreviewParameters
        }

        let context: UICollectionViewDropPlaceholderContext = coordinator.drop(
          item.dragItem, to: placeholder)
        self.dragDropHandler?.dropItem(
          from: item.dragItem.itemProvider, to: UInt(destinationIndex), placeholderContext: context)
      }
    }
  }

  // MARK: - Private

  /// Determines the IndexPath where a dropped UICollectionViewDropItem should be inserted.
  private func dropIndexPath(item: UICollectionViewDropItem, destinationIndex: Int) -> IndexPath {
    let defaultIndexPath = IndexPath(item: destinationIndex, section: 0)

    // Item originates from a different collection view.
    guard let sourceIndexPath = item.sourceIndexPath else {
      return defaultIndexPath
    }

    // Item is dropped before its original position.
    if sourceIndexPath.item > destinationIndex {
      return defaultIndexPath
    }

    guard let draggedItemIdentifier = draggedItemIdentifier,
      let itemData = self.itemData[draggedItemIdentifier] as? TabStripItemData
    else {
      return defaultIndexPath
    }

    // If the tab item is the only item in its group, adjust drop position.
    if itemData.groupStrokeColor != nil && itemData.isFirstTabInGroup && itemData.isLastTabInGroup {
      return IndexPath(item: destinationIndex - 1, section: 0)
    }

    return defaultIndexPath
  }

}