chromium/ios/chrome/browser/ui/tab_switcher/tab_strip/ui/tab_strip_layout.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_ui_tab_switcher_tab_strip_ui_swift_constants

/// Layout used for the TabStrip.
class TabStripLayout: UICollectionViewFlowLayout {
  /// Wether the size of the items in the flow layout needs to be updated.
  public var needsSizeUpdate: Bool = true

  /// Static decoration views that border the collection view.
  public var leadingStaticSeparator: TabStripDecorationView?
  public var trailingStaticSeparator: TabStripDecorationView?

  /// The tab strip new tab button.
  public var newTabButton: UIView?

  /// Whether the selected cell is animated, used only on iOS 16.
  /// On iOS 16, the scroll animation after opening a new tab is delayed, the
  /// selected cell should remain in an animated state until the end of the
  /// (scroll) animation.
  public var cellAnimatediOS16: Bool = false

  /// Dynamic size of a tab.
  private var tabCellSize: CGSize = .zero

  /// Index paths of animated items.
  private var indexPathsOfDeletingItems: [IndexPath] = []
  private var indexPathsOfInsertingItems: [IndexPath] = []

  /// Whether items are currently being collapsed/expanded.
  private var expandingItems = false
  private var collapsingItems = false

  /// The currently selected item.
  public var selectedItem: TabSwitcherItem? = nil

  //// Leading constraint of the `newTabButton`.
  private var newTabButtonLeadingConstraint: NSLayoutConstraint?

  /// The DataSource for this collection view.
  weak var dataSource:
    UICollectionViewDiffableDataSource<TabStripViewController.Section, TabStripItemIdentifier>?

  override init() {
    super.init()
    scrollDirection = .horizontal
    minimumLineSpacing = TabStripConstants.TabItem.horizontalSpacing
    minimumInteritemSpacing = TabStripConstants.TabItem.horizontalSpacing
    sectionInset = UIEdgeInsets(
      top: TabStripConstants.CollectionView.topInset,
      left: TabStripConstants.CollectionView.horizontalInset,
      bottom: 0,
      right: TabStripConstants.CollectionView.horizontalInset)

    NotificationCenter.default.addObserver(
      self, selector: #selector(voiceOverChanged),
      name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override var collectionViewContentSize: CGSize {
    let contentSize = super.collectionViewContentSize

    if !TabStripFeaturesUtils.isModernTabStripNewTabButtonDynamic { return contentSize }
    guard
      let collectionView = collectionView,
      let newTabButton = newTabButton,
      let newTabButtonSuperView = newTabButton.superview
    else { return contentSize }

    var offset: CGFloat =
      TabStripFeaturesUtils.isTabStripCloserNTBEnabled
        || TabStripFeaturesUtils.isTabStripCloserNTBDarkerBackgroundEnabled ? 8 : 0

    // Compare with "width - 1" to avoid floating comparison issues.
    if contentSize.width >= collectionView.bounds.width - 1 {
      // When the contentSize width is greater or equals to the collection view width, the
      // offset should be reduced to allow spacing for the separators.
      offset = 6
    }

    let updatedConstant =
      min(
        contentSize.width, collectionView.bounds.width) - offset

    if newTabButtonLeadingConstraint == nil {
      newTabButtonLeadingConstraint = newTabButton.leadingAnchor.constraint(
        equalTo: newTabButtonSuperView.leadingAnchor,
        constant: updatedConstant)
      newTabButtonLeadingConstraint?.priority = .defaultLow
      newTabButtonLeadingConstraint?.isActive = true
      return contentSize
    }

    if updatedConstant != newTabButtonLeadingConstraint?.constant {
      newTabButtonLeadingConstraint?.constant = updatedConstant
      weak var weakSelf = self
      UIView.animate(
        withDuration: TabStripConstants.NewTabButton.constraintUpdateAnimationDuration, delay: 0.0,
        options: .curveEaseOut,
        animations: {
          weakSelf?.newTabButtonConstraintUpdateAnimationBlock()
        })
    }

    return contentSize
  }

  // MARK: - Properties

  // Returns the selected item index path.
  private var selectedIndexPath: IndexPath? {
    return TabStripItemIdentifier(selectedItem).flatMap {
      dataSource?.indexPath(for: $0)
    }
  }

  // MARK: - UICollectionViewLayout

  override var flipsHorizontallyInOppositeLayoutDirection: Bool {
    return true
  }

  override func prepare() {
    /// Only recalculate the `tabCellSize` when needed to avoid extra
    /// computation.
    if needsSizeUpdate {
      calculateTabCellSize()
    }
    super.prepare()
  }

  override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
    super.prepare(forCollectionViewUpdates: updateItems)

    // Keeps track of updated items to animate their transition.
    indexPathsOfDeletingItems = []
    indexPathsOfInsertingItems = []
    for item in updateItems {
      switch item.updateAction {
      case .insert where !expandingItems:
        indexPathsOfInsertingItems.append(item.indexPathAfterUpdate!)
        break
      case .delete where !collapsingItems:
        indexPathsOfDeletingItems.append(item.indexPathBeforeUpdate!)
        break
      default:
        break
      }
    }
  }

  override func finalizeCollectionViewUpdates() {
    indexPathsOfDeletingItems = []
    indexPathsOfInsertingItems = []
    super.finalizeCollectionViewUpdates()
  }

  override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath)
    -> UICollectionViewLayoutAttributes?
  {
    guard
      let collectionView = collectionView,
      let attributes: UICollectionViewLayoutAttributes = super
        .initialLayoutAttributesForAppearingItem(at: itemIndexPath),
      let itemIdentifier = dataSource?.itemIdentifier(for: itemIndexPath)
    else { return nil }
    switch itemIdentifier.item {
    case .tab(_):
      return initialLayoutAttributesForAppearingTabCell(
        at: itemIndexPath, attributes: attributes, collectionView: collectionView)
    case .group(_):
      return initialLayoutAttributesForAppearingGroupCell(
        at: itemIndexPath, attributes: attributes, collectionView: collectionView)
    }
  }

  override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath)
    -> UICollectionViewLayoutAttributes?
  {
    guard
      let collectionView = collectionView,
      let cell = collectionView.cellForItem(at: itemIndexPath),
      let attributes: UICollectionViewLayoutAttributes =
        super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath)
    else { return nil }

    switch cell {
    case let tabCell as TabStripTabCell:
      return finalLayoutAttributesForDisappearingTabCell(
        tabCell, at: itemIndexPath, attributes: attributes, collectionView: collectionView)
    case let groupCell as TabStripGroupCell:
      return finalLayoutAttributesForDisappearingGroupCell(
        groupCell, at: itemIndexPath, attributes: attributes, collectionView: collectionView)
    default:
      return nil
    }
  }

  override func layoutAttributesForItem(at indexPath: IndexPath)
    -> UICollectionViewLayoutAttributes?
  {
    guard
      let itemIdentifier = dataSource?.itemIdentifier(for: indexPath),
      let layoutAttributes = super.layoutAttributesForItem(at: indexPath),
      let collectionView = collectionView
    else { return nil }
    let cell = collectionView.cellForItem(at: indexPath)
    switch itemIdentifier.item {
    case .tab(_):
      let tabCell = cell as? TabStripTabCell
      return layoutAttributesForTabCell(
        tabCell, at: indexPath, layoutAttributes: layoutAttributes,
        collectionView: collectionView)
    case .group(_):
      let groupCell = cell as? TabStripGroupCell
      return layoutAttributesForGroupCell(
        groupCell, at: indexPath, layoutAttributes: layoutAttributes,
        collectionView: collectionView)
    }
  }

  private func layoutAttributesForTabCell(
    _ cell: TabStripTabCell?, at indexPath: IndexPath,
    layoutAttributes: UICollectionViewLayoutAttributes, collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    /// Early return the updated `selectedAttributes` if the cell is selected.
    if let selectedAttributes = self.layoutAttributesForSelectedTabCell(
      cell, at: indexPath, layoutAttributes: layoutAttributes, collectionView: collectionView)
    {
      return selectedAttributes
    }

    guard let cell = cell else { return layoutAttributes }

    layoutAttributes.zIndex = 0

    let contentOffset = collectionView.contentOffset
    var frame = layoutAttributes.frame
    let collectionViewWidth = collectionView.bounds.size.width

    let leftBounds: CGFloat = contentOffset.x + sectionInset.left
    let rightBounds: CGFloat = collectionViewWidth + contentOffset.x - sectionInset.right
    let isScrollable: Bool = collectionView.contentSize.width > collectionView.frame.width

    /// Hide the `trailingSeparator`if the next cell is selected.
    let isNextCellSelected = (indexPath.item + 1) == selectedIndexPath?.item
    cell.trailingSeparatorHidden = isNextCellSelected

    /// Hide the `leadingSeparator` if the previous cell is selected or this is the first cell and collection
    /// view is not scrollable, or the previous cell is a group.
    let indexPathOfPreviousItem = IndexPath(item: indexPath.item - 1, section: indexPath.section)
    let isFirstCellAndNotScrollable = !isScrollable && (indexPath.item == 0)
    let isPreviousCellSelected = indexPathOfPreviousItem == selectedIndexPath
    cell.leadingSeparatorHidden =
      isPreviousCellSelected || isFirstCellAndNotScrollable || cell.isFirstTabInGroup

    if UIAccessibility.isVoiceOverRunning {
      // Prevent frame resizing while VoiceOver is active.
      // This ensures swiping right/left goes to the next cell.
      return layoutAttributes
    }

    var intersectsLeftEdge = false
    var intersectsRightEdge = false

    /// Recalculate the cell width and origin when it intersects with the left
    /// collection view's bounds. The cell should collapse within the collection
    /// view's bounds until its width reaches 0. Its `separatorHeight` is also
    /// reduced when the cell reached an edege.
    var separatorHeight = TabStripConstants.AnimatedSeparator.regularSeparatorHeight
    if isScrollable && (frame.minX < leftBounds || frame.maxX > rightBounds) {
      // Show leading and trailing gradient.
      cell.leadingSeparatorGradientViewHidden = false
      cell.trailingSeparatorGradientViewHidden = false

      let collapseThreshold = TabStripConstants.AnimatedSeparator.collapseHorizontalInsetThreshold
      let collapseHorizontalInset = TabStripConstants.AnimatedSeparator.collapseHorizontalInset

      // If intersects with the left bounds.
      if frame.minX < leftBounds {
        cell.leadingSeparatorHidden = false
        intersectsLeftEdge = true

        // Update the frame origin and width.
        frame.origin.x = max(leftBounds, frame.origin.x)
        let offsetLeft: CGFloat = abs(frame.origin.x - layoutAttributes.frame.origin.x)
        frame.size.width = min(frame.size.width - offsetLeft, frame.size.width)

        /// Start animating the cell out of the collection view  if  the new
        /// width `frame.size.width` is less than or equal to
        /// `collapseThreshold`.
        if frame.size.width <= collapseThreshold {

          // Update the size of the separator.
          separatorHeight = calculateSeparatorsHeight(for: frame.size.width)

          // Move the cell to the left until it reaches its final position.
          frame.origin.x = max(
            frame.origin.x - collapseThreshold + frame.size.width,
            leftBounds - collapseHorizontalInset)

          // Update its alpha value.
          layoutAttributes.alpha = calculateAlphaValue(for: abs(frame.size.width))

          // Set its width to 0 and update its separators.
          frame.size.width = 0
          cell.trailingSeparatorHidden = true
          cell.leadingSeparatorGradientViewHidden = true
          cell.trailingSeparatorGradientViewHidden = true
        }
      }

      // If intersects with the right bounds.
      else if frame.maxX > rightBounds {
        cell.trailingSeparatorHidden = false
        intersectsRightEdge = true

        // Update the frame origin and width.
        frame.origin.x = min(rightBounds, frame.origin.x)
        frame.size.width = min(rightBounds - frame.origin.x, frame.size.width)

        /// Start animating the cell out of the collection view  if the new
        ///  width `frame.size.width` is less than or equal to
        ///  `collapseThreshold`.
        if frame.size.width <= collapseThreshold {

          // Update the size of the separator.
          separatorHeight = calculateSeparatorsHeight(for: frame.size.width)

          // Move the cell to the right until it reaches its final position.
          frame.origin.x = min(
            frame.origin.x + collapseThreshold - frame.size.width,
            rightBounds + collapseHorizontalInset)

          // Update its alpha value.
          let offset = layoutAttributes.frame.minX - frame.minX + collapseHorizontalInset
          layoutAttributes.alpha = calculateAlphaValue(for: offset)

          // Update its width.
          frame.size.width = max(
            min(rightBounds + collapseHorizontalInset - frame.origin.x, frame.size.width), 0)

          // Update its separators.
          cell.leadingSeparatorHidden = true
          cell.leadingSeparatorGradientViewHidden = true
          cell.trailingSeparatorGradientViewHidden = true
        }
      }
    }

    // Update separators height once the computation is done.
    cell.setSeparatorsHeight(separatorHeight)
    cell.intersectsLeftEdge = intersectsLeftEdge
    cell.intersectsRightEdge = intersectsRightEdge

    layoutAttributes.frame = frame
    return layoutAttributes
  }

  private func layoutAttributesForGroupCell(
    _ groupCell: TabStripGroupCell?, at indexPath: IndexPath,
    layoutAttributes: UICollectionViewLayoutAttributes, collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    if UIAccessibility.isVoiceOverRunning {
      // Prevent frame resizing while VoiceOver is active.
      // This ensures swiping right/left goes to the next cell.
      return layoutAttributes
    }

    guard let groupCell = groupCell else { return layoutAttributes }

    let contentOffset = collectionView.contentOffset
    var frame = layoutAttributes.frame
    let collectionViewWidth = collectionView.bounds.size.width

    let leftBounds: CGFloat = contentOffset.x + sectionInset.left
    let rightBounds: CGFloat = collectionViewWidth + contentOffset.x - sectionInset.right
    let isScrollable: Bool = collectionView.contentSize.width > collectionView.frame.width

    var intersectsLeftEdge = false
    var intersectsRightEdge = false

    /// Recalculate the cell width and origin when it intersects with the left
    /// collection view's bounds. The cell should collapse within the collection
    /// view's bounds until its width reaches 0.
    if isScrollable && (frame.minX < leftBounds || frame.maxX > rightBounds) {
      let minCellWidth = TabStripConstants.GroupItem.minCellWidth

      // If intersects with the left bounds.
      if frame.minX < leftBounds {
        intersectsLeftEdge = true
        // Update the frame origin and width.
        frame.origin.x = max(leftBounds, frame.origin.x)
        let offsetLeft: CGFloat = abs(frame.origin.x - layoutAttributes.frame.origin.x)
        frame.size.width = min(frame.size.width - offsetLeft, frame.size.width)

        /// Start animating the cell out of the collection view  if  the new
        /// width `frame.size.width` is less than or equal to
        /// `collapseThreshold`.
        if frame.size.width <= minCellWidth {
          // Move the cell to the left until it reaches its final position.
          frame.origin.x = frame.origin.x - minCellWidth + frame.size.width
          frame.size.width = minCellWidth
        }
      }

      // If intersects with the right bounds.
      else if frame.maxX > rightBounds {
        intersectsRightEdge = true
        // Update the frame origin and width.
        frame.size.width = min(rightBounds - frame.origin.x, frame.size.width)

        /// Start animating the cell out of the collection view  if the new
        ///  width `frame.size.width` is less than or equal to
        ///  `collapseThreshold`.
        if frame.size.width <= minCellWidth {
          frame.size.width = minCellWidth
        }
      }
    }

    groupCell.intersectsLeftEdge = intersectsLeftEdge
    groupCell.intersectsRightEdge = intersectsRightEdge

    layoutAttributes.frame = frame
    return layoutAttributes
  }

  override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
  {
    let rectToConsider = CGRectInset(rect, -2 * TabStripConstants.TabItem.maxWidth, 0)
    guard
      let superAttributes = super.layoutAttributesForElements(in: rectToConsider)
    else { return nil }

    var indexPathToConsider = superAttributes.map(\.indexPath)
    // If there is a selected tab, and its index path is one of the visible
    // index paths of the collection view, add it to the list of index paths to
    // consider.
    if let selectedIndexPath = selectedIndexPath,
      collectionView?.indexPathsForVisibleItems.contains(selectedIndexPath) == true
    {
      if !indexPathToConsider.contains(selectedIndexPath) {
        indexPathToConsider.append(selectedIndexPath)
      }
    }

    return indexPathToConsider.compactMap { indexPath in
      layoutAttributesForItem(at: indexPath)
    }

  }

  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    return true
  }

  // MARK: - Private

  /// Animation block executed when `newTabButtonLeadingConstraint` is updated.
  private func newTabButtonConstraintUpdateAnimationBlock() {
    newTabButton?.superview?.layoutIfNeeded()
  }

  /// Returns the initial layout attributes for an appearing `tabCell`.
  private func initialLayoutAttributesForAppearingTabCell(
    at itemIndexPath: IndexPath,
    attributes: UICollectionViewLayoutAttributes, collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    let tabCell = collectionView.cellForItem(at: itemIndexPath) as? TabStripTabCell
    guard
      let selectedAttributes: UICollectionViewLayoutAttributes =
        self.layoutAttributesForSelectedTabCell(
          tabCell, at: itemIndexPath, layoutAttributes: attributes, collectionView: collectionView)
    else { return nil }

    if indexPathsOfInsertingItems.contains(itemIndexPath) {
      // Animate the appearing item by starting it with zero opacity and
      // translated down by its height.
      selectedAttributes.alpha = 0
      selectedAttributes.transform = CGAffineTransform(
        translationX: 0,
        y: attributes.frame.size.height)
    }
    return selectedAttributes
  }

  /// Returns the initial layout attributes for an appearing `groupCell`.
  private func initialLayoutAttributesForAppearingGroupCell(
    at itemIndexPath: IndexPath, attributes: UICollectionViewLayoutAttributes,
    collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    return attributes
  }

  /// Updates and returns the given `attributes` if the cell is selected.
  /// Inserted items are considered as selected.
  private func layoutAttributesForSelectedTabCell(
    _ cell: TabStripTabCell?, at indexPath: IndexPath,
    layoutAttributes: UICollectionViewLayoutAttributes, collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    // The selected cell should remain on top of other cells within collection
    // view's bounds.
    guard
      indexPath == selectedIndexPath || indexPathsOfInsertingItems.contains(indexPath)
    else {
      return nil
    }

    /// `cellAnimatediOS16` is always `false` above iOS 16.
    var cellAnimated = cellAnimatediOS16
    if let animationKeys = cell?.layer.animationKeys() {
      cellAnimated = !animationKeys.isEmpty || cellAnimatediOS16
    }

    var intersectsLeftEdge = false
    var intersectsRightEdge = false

    // Update cell separators.
    cell?.leadingSeparatorHidden = true
    cell?.trailingSeparatorHidden = true
    cell?.leadingSeparatorGradientViewHidden = true
    cell?.trailingSeparatorGradientViewHidden = true

    let isScrollable: Bool = collectionView.contentSize.width > collectionView.frame.width
    let collectionViewWidth = collectionView.bounds.size.width
    let horizontalOffset = collectionView.contentOffset.x
    let frame = layoutAttributes.frame
    var origin = layoutAttributes.frame.origin
    var horizontalInset: CGFloat = 0

    // Add a static separator horizontal inset only if the selected cell is the
    // first or the last one. Otherwise, when the selected cell is anchored and
    // a cell is scrolled behind, only one separator is displayed until the
    // horizontal inset threshold is reached.
    var staticSeparatorHorizontalInset: CGFloat = 0
    if let dataSource = dataSource, let sectionIndex = dataSource.index(for: .tabs) {
      let itemCount = dataSource.collectionView(
        collectionView, numberOfItemsInSection: sectionIndex)
      if indexPath.item == 0 || indexPath.item == itemCount - 1 {
        staticSeparatorHorizontalInset =
          tabCellSize.width - TabStripConstants.AnimatedSeparator.collapseHorizontalInsetThreshold
      }
    }

    var hideLeadingStaticSeparator = true
    var hideTrailingStaticSeparator = true

    // If the collection view is scrollable, add an horizontal inset to its
    // origin.
    if isScrollable {
      horizontalInset = TabStripConstants.TabItem.horizontalSelectedInset
    }

    // Update the cell's origin horizontally to prevent it from being
    // partially hidden off-screen.

    // Check the left side.
    let minOringin = horizontalOffset + sectionInset.left + horizontalInset
    // Show leading static separators when all of the following conditions are
    // satisfied:
    // - The selected cell is on the leading edge.
    // - A cell behind the selected cell is also reaching the leading edge.
    // - The cell is not animated (inserted / deleted).
    if (minOringin - staticSeparatorHorizontalInset) >= origin.x {
      hideLeadingStaticSeparator = !isScrollable || cellAnimated
    }
    if origin.x < minOringin {
      origin.x = minOringin
      intersectsLeftEdge = true
    }

    // Check the right side.
    let maxOrigin =
      horizontalOffset + collectionViewWidth - frame.size.width - sectionInset.right
      - horizontalInset
    // Show right static separators when all of the following conditions are
    // satisfied:
    // - The selected cell is on the right edge.
    // - A cell behind the selected cell is also reaching the right edge.
    // - The cell is not animated (inserted / deleted).
    if (maxOrigin + staticSeparatorHorizontalInset) <= origin.x {
      hideTrailingStaticSeparator = !isScrollable || cellAnimated
    }
    if origin.x > maxOrigin {
      origin.x = maxOrigin
      intersectsRightEdge = true
    }

    cell?.intersectsLeftEdge = intersectsLeftEdge
    cell?.intersectsRightEdge = intersectsRightEdge

    leadingStaticSeparator?.isHidden = hideLeadingStaticSeparator
    trailingStaticSeparator?.isHidden = hideTrailingStaticSeparator
    cell?.leadingSelectedBorderBackgroundViewHidden = hideLeadingStaticSeparator
    cell?.trailingSelectedBorderBackgroundViewHidden = hideTrailingStaticSeparator

    layoutAttributes.frame = CGRect(origin: origin, size: frame.size)
    layoutAttributes.zIndex = TabStripConstants.TabItem.selectedZIndex
    return layoutAttributes
  }

  /// Returns the final layout attributes for andisappearing `tabCell`.
  private func finalLayoutAttributesForDisappearingTabCell(
    _ tabCell: TabStripTabCell?, at itemIndexPath: IndexPath,
    attributes: UICollectionViewLayoutAttributes, collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    var attributes = attributes
    /// Update `attributes` if the disappearing cell is selected.
    if let selectedAttributes = self.layoutAttributesForSelectedTabCell(
      tabCell, at: itemIndexPath, layoutAttributes: attributes, collectionView: collectionView)
    {
      attributes = selectedAttributes
    }

    if indexPathsOfDeletingItems.contains(itemIndexPath) {
      tabCell?.leadingSeparatorHidden = true
      tabCell?.trailingSeparatorHidden = true

      // Animate the disappearing item by fading it out and translating it down
      // by its height.
      attributes.alpha = 0
      attributes.transform = CGAffineTransform(
        translationX: 0,
        y: attributes.frame.size.height
      )
    }
    return attributes
  }

  /// Returns the final layout attributes for a disappearing `groupCell`.
  private func finalLayoutAttributesForDisappearingGroupCell(
    _ groupCell: TabStripGroupCell?, at itemIndexPath: IndexPath,
    attributes: UICollectionViewLayoutAttributes, collectionView: UICollectionView
  )
    -> UICollectionViewLayoutAttributes?
  {
    return attributes
  }

  /// This function calculates the separator height value for a given
  /// `frameWidth`. The returned value will always be within the range of
  /// `regularSeparatorHeight` and `minSeparatorHeight`.
  private func calculateSeparatorsHeight(for frameWidth: CGFloat) -> CGFloat {
    let regularHeight = TabStripConstants.AnimatedSeparator.regularSeparatorHeight
    let separatorHeight = min(
      regularHeight,
      regularHeight + frameWidth
        - TabStripConstants.AnimatedSeparator.collapseHorizontalInsetThreshold)
    return max(TabStripConstants.AnimatedSeparator.minSeparatorHeight, separatorHeight)
  }

  /// Calculates the alpha value for the given `offset`.
  private func calculateAlphaValue(for offset: CGFloat) -> CGFloat {
    var alpha: CGFloat = 1

    /// If the sum of `offset` and `collapseHorizontalInset` exceeds
    /// the `tabCellSize.width`, that means the cell will almost disappear from
    /// the collection view. Its alpha value should be reduced.
    let distance =
      offset + TabStripConstants.AnimatedSeparator.collapseHorizontalInset - tabCellSize.width
    if distance > 0 {
      alpha = max(0, 1 - distance / TabStripConstants.TabItem.maximumVisibleDistance)
    }

    return alpha
  }

  // Called when voice over is activated.
  @objc func voiceOverChanged() {
    self.invalidateLayout()
  }

  // MARK: - Public

  // Calculates the dynamic size of a tab according to the number of tabs and
  // groups.
  public func calculateTabCellSize() {
    guard let collectionView = self.collectionView, let snapshot = dataSource?.snapshot(for: .tabs)
    else {
      return
    }

    var groupCellWidthSum: CGFloat = 0
    var tabCellCount: CGFloat = 0
    let cellCount: CGFloat = CGFloat(snapshot.visibleItems.count)

    if cellCount == 0 {
      return
    }

    for itemIdentifier in snapshot.visibleItems {
      switch itemIdentifier.item {
      case .tab(_):
        tabCellCount += 1
      case .group(let tabGroupItem):
        groupCellWidthSum += calculateCellSizeForTabGroupItem(tabGroupItem).width
      }
    }

    let collectionViewWidth: CGFloat = CGRectGetWidth(collectionView.bounds)
    let itemSpacingSum: CGFloat =
      minimumLineSpacing * (cellCount - 1) + sectionInset.left + sectionInset.right

    var itemWidth: CGFloat =
      (collectionViewWidth - itemSpacingSum - groupCellWidthSum) / tabCellCount
    itemWidth = max(itemWidth, TabStripConstants.TabItem.minWidth)
    itemWidth = min(itemWidth, TabStripConstants.TabItem.maxWidth)

    tabCellSize = CGSize(width: itemWidth, height: TabStripConstants.TabItem.height)
  }

  public func calculateCellSizeForTabSwitcherItem(_ tabSwitcherItem: TabSwitcherItem) -> CGSize {
    return tabCellSize
  }

  public func calculateCellSizeForTabGroupItem(_ tabGroupItem: TabGroupItem) -> CGSize {
    var width =
      tabGroupItem.title?.size(withAttributes: [
        .font: UIFont.systemFont(ofSize: TabStripConstants.GroupItem.fontSize, weight: .medium)
      ]).width ?? 0
    width += 2 * TabStripConstants.GroupItem.titleContainerHorizontalMargin
    width += 2 * TabStripConstants.GroupItem.titleContainerHorizontalPadding
    width = min(width, TabStripConstants.GroupItem.maxCellWidth)
    return CGSize(width: width, height: TabStripConstants.GroupItem.height)
  }

  /// Prepares the layout for collection view updates resulting from expanding items.
  public func prepareForItemsExpanding() {
    self.expandingItems = true
  }

  /// Finalizes collection view updates resulting from expanding items.
  public func finalizeItemsExpanding() {
    self.expandingItems = false
  }

  /// Prepares the layout for collection view updates resulting from collapsing items.
  public func prepareForItemsCollapsing() {
    self.collapsingItems = true
  }

  /// Finalizes collection view updates resulting from collapsing items.
  public func finalizeItemsCollapsing() {
    self.collapsingItems = false
  }
}