chromium/ios/chrome/browser/ui/popup_menu/overflow_menu/overflow_menu_destination_list.swift

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

import Combine
import SwiftUI

/// Compatibility modifier to allow easy usage of `.scrollClipDisabled`
/// introduced in iOS 17.
struct ScrollClipDisabledCompat: ViewModifier {
  var disabled: Bool
  func body(content: Content) -> some View {
    #if swift(>=5.9)
      if #available(iOS 17, *) {
        return content.scrollClipDisabled(disabled)
      }
    #endif
    return content
  }
}

extension View {
  func scrollClipDisabledCompat(_ disabled: Bool = true) -> some View {
    modifier(ScrollClipDisabledCompat(disabled: disabled))
  }
}

/// A view displaying a list of destinations.
struct OverflowMenuDestinationList: View {
  enum Constants {
    /// Padding breakpoints for each width. The ranges should be inclusive of
    /// the larger number. That is, a width of 320 should fall in the
    /// `(230, 320]` bucket.
    static let widthBreakpoints: [CGFloat] = [
      180, 230, 320, 400, 470, 560, 650,
    ]

    /// Array of the lower end of each breakpoint range.
    static let lowerWidthBreakpoints = [nil] + widthBreakpoints

    /// Array of the higher end of each breakpoint range.
    static let upperWidthBreakpoints = widthBreakpoints + [nil]

    /// Leading space on the first icon.
    static let iconInitialSpace: CGFloat = 16

    /// Range of spacing around icons; varies based on view width.
    static let iconSpacingRange: ClosedRange<CGFloat> = 9...13

    /// Range of icon paddings; varies based on view width.
    static let iconPaddingRange: ClosedRange<CGFloat> = 0...3

    /// The top margin between the destinations and the edge of the list.
    static let defaultTopMargin: CGFloat = 15

    static let defaultBottomMargin: CGFloat = 8

    /// The name for the coordinate space of the scroll view, so children can
    /// find their positioning in the scroll view.
    static let coordinateSpaceName = "destinations"
  }

  /// `PreferenceKey` to track the leading offset of the scroll view.
  struct ScrollViewLeadingOffset: PreferenceKey {
    static var defaultValue: CGFloat = .greatestFiniteMagnitude
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
      value = min(value, nextValue())
    }
  }

  /// `PreferenceKey` to track the highlighted destination's bounds in its local coordinate space,
  /// can be transformed to other coordinate space by geometry reader.
  struct HighlightedDestinationBounds: PreferenceKey {
    typealias Value = Anchor<CGRect>?
    static var defaultValue: Value = nil
    static func reduce(value: inout Value, nextValue: () -> Value) {
      // AnchorPreference might be nil in the middle of layout.
      if let next = nextValue() {
        value = next
      }
    }
  }

  /// The current dynamic type size.
  @Environment(\.sizeCategory) var sizeCategory

  /// The current environment layout direction.
  @Environment(\.layoutDirection) var layoutDirection: LayoutDirection

  @Environment(\.editMode) var editMode

  /// The destinations for this view.
  @Binding var destinations: [OverflowMenuDestination]

  // The allotted width of this view.
  var width: CGFloat

  weak var metricsHandler: PopupMenuMetricsHandler?

  @ObservedObject var uiConfiguration: OverflowMenuUIConfiguration

  // The drag handler to use for drag interactions on this list
  @ObservedObject var dragHandlerContainer: DestinationDragHandlerContainer

  /// The namespace for the animation of this view appearing or disappearing.
  let namespace: Namespace.ID

  /// Tracks the list's current offset, to see when it scrolls. When the offset
  /// is `nil`, scroll tracking is not set up yet. This is necessary because
  /// in RTL languages, the scroll view has to manually scroll to the right edge
  /// of the list first.
  @State var listOffset: CGFloat? = nil

  init(
    destinations: Binding<[OverflowMenuDestination]>,
    width: CGFloat,
    metricsHandler: PopupMenuMetricsHandler? = nil,
    uiConfiguration: OverflowMenuUIConfiguration,
    dragHandler: DestinationDragHandler? = nil,
    namespace: Namespace.ID
  ) {
    self._destinations = destinations
    self.width = width
    self.metricsHandler = metricsHandler
    self.uiConfiguration = uiConfiguration
    dragHandlerContainer = DestinationDragHandlerContainer(dragHandler: dragHandler)
    self.namespace = namespace
  }

  var body: some View {
    scrollView
      .coordinateSpace(name: Constants.coordinateSpaceName)
      .accessibilityIdentifier(kPopupMenuToolsMenuTableViewId)
      .background(
        Color("destination_highlight_color").opacity(
          uiConfiguration.highlightDestinationsRow ? 1 : 0)
      )
      .animation(
        .linear(duration: kMaterialDuration3), value: uiConfiguration.highlightDestinationsRow
      )
      .onPreferenceChange(ScrollViewLeadingOffset.self) { newOffset in
        // Only alert the handler if scroll tracking has started.
        if let listOffset = listOffset,
          newOffset != listOffset
        {
          metricsHandler?.popupMenuScrolledHorizontally()
        }
        // Only update the offset if scroll tracking has started or the newOffset
        // is approximately 0 (this starts scroll tracking). In RTL mode, the
        // offset is not exactly 0, so a strict comparison won't work.
        if listOffset != nil || (listOffset == nil && abs(newOffset) < 1e-9) {
          listOffset = newOffset
        }
      }
  }

  @ViewBuilder
  private var scrollView: some View {
    ScrollViewReader { proxy in
      ScrollView(.horizontal, showsIndicators: false) {
        let spacing = OverflowMenuDestinationList.destinationSpacing(
          forScreenWidth: width)
        let layoutParameters = OverflowMenuDestinationList.layoutParameters(
          forScreenWidth: width, forSizeCategory: sizeCategory)
        let alignment: VerticalAlignment = sizeCategory >= .accessibilityMedium ? .center : .icon
        HStack(alignment: alignment, spacing: 0) {
          // Make sure the space to the first icon is constant, so add extra
          // spacing before the first item.
          Spacer().frame(width: Constants.iconInitialSpace - spacing.iconSpacing)
          ForEach(destinations) { destination in
            let destinationView = OverflowMenuDestinationView(
              destination: destination, layoutParameters: layoutParameters,
              highlighted: uiConfiguration.highlightDestination == destination.destination,
              metricsHandler: metricsHandler
            )
            let destinationBeingDragged =
              dragHandlerContainer.dragHandler?.dragOnDestinations ?? false
              && dragHandlerContainer.dragHandler?.currentDrag?.item == destination
            destinationView
              .id(destination.destination)
              .ifLet(dragHandlerContainer.dragHandler) { view, dragHandler in
                view
                  .opacity(destinationBeingDragged ? 0.01 : 1)
                  .onDrag {
                    dragHandler.startDrag(from: destination)
                    return dragHandler.newItemProvider(forDestination: destination)
                  }
                  .onDrop(
                    of: [.text],
                    delegate: dragHandler.newDropDelegate(
                      forDestination: destination))
              }
              .overlay(alignment: .editButton) {
                if !destinationBeingDragged && editMode?.wrappedValue.isEditing == true
                  && destination.canBeHidden
                {
                  DestinationEditButton(destination: destination)
                    .alignmentGuide(HorizontalAlignment.editButton) {
                      $0[HorizontalAlignment.center]
                    }
                    .alignmentGuide(VerticalAlignment.editButton) { $0[VerticalAlignment.center] }
                }
              }
              .matchedGeometryEffect(
                id: MenuCustomizationAnimationID.from(destination), in: namespace
              )
              .accessibilityElement(children: .combine)
              .accessibilityHint(editButtonAccessibilityHint(for: destination))
          }
        }
        .fixedSize(horizontal: false, vertical: true)
        .padding([.top], Constants.defaultTopMargin)
        .padding([.bottom], Constants.defaultBottomMargin)
        .overlay {
          GeometryReader { innerGeometry in
            let frame = innerGeometry.frame(in: .named(Constants.coordinateSpaceName))

            // When the view is RTL, the offset should be calculated from the
            // right edge.
            let offset = layoutDirection == .leftToRight ? frame.minX : width - frame.maxX

            Color.clear
              .preference(key: ScrollViewLeadingOffset.self, value: offset)
          }
        }
      }
      .scrollClipDisabledCompat()
      .background {
        GeometryReader { geometry in
          Color.clear.onAppear {
            uiConfiguration.destinationListScreenFrame = geometry.frame(in: .global)
          }
        }
      }
      .onAppear {
        if destinations.map(\.destination).contains(uiConfiguration.highlightDestination) {
          proxy.scrollTo(uiConfiguration.highlightDestination)
        } else if layoutDirection == .rightToLeft {
          proxy.scrollTo(destinations.first?.destination)
        }
      }
    }
  }

  private func editButtonAccessibilityHint(for destination: OverflowMenuDestination) -> String {
    guard editMode?.wrappedValue.isEditing == true && destination.canBeHidden else {
      return ""
    }
    return destination.shown
      ? L10nUtils.stringWithFixup(
        messageId: IDS_IOS_OVERFLOW_MENU_HIDE_ITEM_ACCESSIBILITY_HINT)
      : L10nUtils.stringWithFixup(messageId: IDS_IOS_OVERFLOW_MENU_SHOW_ITEM_ACCESSIBILITY_HINT)
  }

  /// Finds the lower and upper breakpoint above and below `width`.
  ///
  /// Returns `nil` for either end if `width` is above or below the largest or
  /// smallest breakpoint.
  private static func findBreakpoints(forScreenWidth width: CGFloat) -> (CGFloat?, CGFloat?) {
    // Add extra sentinel values to either end of the breakpoint array.
    let x = zip(
      Constants.lowerWidthBreakpoints, Constants.upperWidthBreakpoints
    )
    // There should only be one item where the provided width is both greater
    // than the lower end and less than the upper end.
    .filter {
      (low, high) in
      // Check if width is above the low value, or default to true if low is
      // nil.
      let aboveLow = low.map { value in width > value } ?? true
      let belowHigh = high.map { value in width <= value } ?? true
      return aboveLow && belowHigh
    }.first
    return x ?? (nil, nil)
  }

  /// Calculates the icon spacing and padding for the given `width`.
  private static func destinationSpacing(forScreenWidth width: CGFloat) -> (
    iconSpacing: CGFloat, iconPadding: CGFloat
  ) {
    let (lowerBreakpoint, upperBreakpoint) = findBreakpoints(
      forScreenWidth: width)

    // If there's no lower breakpoint, `width` is lower than the lowest, so
    // default to the lower bound of the ranges.
    guard let lowerBreakpoint = lowerBreakpoint else {
      return (
        iconSpacing: Constants.iconSpacingRange.lowerBound,
        iconPadding: Constants.iconPaddingRange.lowerBound
      )
    }

    // If there's no upper breakpoint, `width` is higher than the highest, so
    // default to the higher bound of the ranges.
    guard let upperBreakpoint = upperBreakpoint else {
      return (
        iconSpacing: Constants.iconSpacingRange.upperBound,
        iconPadding: Constants.iconPaddingRange.upperBound
      )
    }

    let breakpointRange = lowerBreakpoint...upperBreakpoint

    let iconSpacing = mapNumber(
      width, from: breakpointRange, to: Constants.iconSpacingRange)
    let iconPadding = mapNumber(
      width, from: breakpointRange, to: Constants.iconPaddingRange)
    return (iconSpacing: iconSpacing, iconPadding: iconPadding)
  }

  private static func layoutParameters(
    forScreenWidth width: CGFloat, forSizeCategory sizeCategory: ContentSizeCategory
  ) -> OverflowMenuDestinationView.LayoutParameters {
    let spacing = OverflowMenuDestinationList.destinationSpacing(forScreenWidth: width)

    return sizeCategory >= .accessibilityMedium
      ? .horizontal
      : .vertical(
        iconSpacing: spacing.iconSpacing,
        iconPadding: spacing.iconPadding)
  }

  public static func numDestinationsVisibleWithoutHorizontalScrolling(
    forScreenWidth width: CGFloat, forSizeCategory sizeCategory: ContentSizeCategory
  )
    -> CGFloat
  {
    let layoutParameters = OverflowMenuDestinationList.layoutParameters(
      forScreenWidth: width, forSizeCategory: sizeCategory)

    switch layoutParameters {
    case .vertical(let iconSpacing, let iconPadding):
      let destinationWidth = OverflowMenuDestinationView.verticalLayoutDestinationWidth(
        iconSpacing: iconSpacing, iconPadding: iconPadding)

      return (width / destinationWidth).rounded(.up)
    case .horizontal:
      // In horizontal layout, the width of an individual item depends on the
      // text length. However, it'll always be pretty long, so 2 is a good
      // estimate.
      return 2
    }

  }

  /// Maps the given `number` from its relative position in `inRange` to its
  /// relative position in `outRange`.
  private static func mapNumber<F: FloatingPoint>(
    _ number: F, from inRange: ClosedRange<F>, to outRange: ClosedRange<F>
  ) -> F {
    let scalingFactor =
      (outRange.upperBound - outRange.lowerBound)
      / (inRange.upperBound - inRange.lowerBound)
    return (number - inRange.lowerBound) * scalingFactor + outRange.lowerBound
  }
}

extension VerticalAlignment {
  /// A new custom alignment to align the DestinationViews by their icon
  /// position.
  static let icon = VerticalAlignment(Icon.self)

  /// A new custom alignment to allow aligning the edit buttons at specific
  /// locations.
  static let editButton = VerticalAlignment(EditButton.self)

  private enum Icon: AlignmentID {
    static func defaultValue(in d: ViewDimensions) -> CGFloat {
      return d[.bottom]
    }
  }

  private enum EditButton: AlignmentID {
    static func defaultValue(in d: ViewDimensions) -> CGFloat {
      return d[.top]
    }
  }
}

extension HorizontalAlignment {
  /// A new custom alignment to allow aligning the edit buttons at specific
  /// locations.
  static let editButton = HorizontalAlignment(EditButton.self)

  private enum EditButton: AlignmentID {
    static func defaultValue(in d: ViewDimensions) -> CGFloat {
      return d[.leading]
    }
  }
}

extension Alignment {
  /// A new custom alignment to allow aligning the edit buttons at specific
  /// locations.
  static let editButton = Alignment(horizontal: .editButton, vertical: .editButton)
}

/// Before iOS 17, it was not possible to directly observe optional objects
/// e.g.
/// ```
/// @ObservedObject var myOptional: Foo?
/// ```
/// `DestinationDragHandler` is often optional, so this simple class wraps it
/// in a container that just re-publishes any changes to the underlying drag
/// handler.
/// The `Observable` macro in iOS 17 looks to also solve this issue, and
/// this should be migrateable once iOS 17 is the minimum version supported.
class DestinationDragHandlerContainer: ObservableObject {
  // The underlying drag handler.
  let dragHandler: DestinationDragHandler?

  var cancellable: AnyCancellable?

  init(dragHandler: DestinationDragHandler?) {
    self.dragHandler = dragHandler

    cancellable = dragHandler?.objectWillChange.sink { [weak self] in
      self?.objectWillChange.send()
    }
  }
}