chromium/ios/chrome/browser/ui/popup_menu/overflow_menu/destination_drag_handler.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 SwiftUI

/// Class to handle destination drag interactions in the overflow menu
/// customization flow.
class DestinationDragHandler: ObservableObject {
  /// Collection of data for the current drag interaction.
  struct CurrentDragData {
    var item: OverflowMenuDestination
    var initialIndex: Int
    var initialDestinationUsageEnabled: Bool
  }

  /// The destination customization model underlying the session.
  @ObservedObject var destinationModel: DestinationCustomizationModel

  /// The data for the current drag session. `nil` if nothing is being dragged.
  @Published private(set) var currentDrag: CurrentDragData? = nil

  /// Whether the drag is currently over the destinations row. This is necessary
  /// because the drag system doesn't currently offer a way to tell in general
  /// when a drag ends, so the state needs to be correct for whenever the drag
  /// happens to end.
  @Published var dragOnDestinations = false

  /// Stores all currently existing item providers weakly.
  var providers: [WeakItemProviderContainer] = []

  init(destinationModel: DestinationCustomizationModel) {
    self.destinationModel = destinationModel
  }

  /// Begin a new drag of the given `destination`.
  func startDrag(from item: OverflowMenuDestination) {
    // End any existing drag in case it wasn't able to be ended before.
    endDrag()
    guard let index = destinationModel.shownDestinations.firstIndex(of: item) else {
      return
    }
    currentDrag = CurrentDragData(
      item: item, initialIndex: index,
      initialDestinationUsageEnabled: destinationModel.destinationUsageEnabled)
  }

  /// Ends the current drag state.
  func endDrag() {
    currentDrag = nil
    dragOnDestinations = false
  }

  /// Returns a new drop delegate for drop actions on the provided
  /// `destination`.
  func newDropDelegate(forDestination destination: OverflowMenuDestination)
    -> DestinationDropDelegate
  {
    return DestinationDropDelegate(handler: self, destination: destination)
  }

  /// Returns a new drop delegate for the entire destination list.
  func newDestinationListDropDelegate() -> DestinationListDropDelegate {
    return DestinationListDropDelegate(handler: self)
  }

  /// Returns a new item provider for the drag interaction.
  func newItemProvider(forDestination destination: OverflowMenuDestination) -> NSItemProvider {
    let itemProvider = DidEndItemProvider(object: destination.name as NSString)
    itemProvider.delegate = self
    // When VoiceOver is enabled, sometimes the system requests 2 providers
    // before deallocating one, so storing all existing providers is required
    // to know when all providers have been destroyed.
    providers.append(WeakItemProviderContainer(provider: itemProvider))
    return itemProvider
  }

  /// Performs a drop into a list. Should be passed to `List`'s `.onInsert`
  /// method.
  func performListDrop(index: Int, providers: [NSItemProvider]) {
    currentDrag?.item.shown = false
  }

  /// Drop delegate for an individual destination. This is used for drop actions
  /// when the user's finger is over the given destination.
  struct DestinationDropDelegate: DropDelegate {
    let handler: DestinationDragHandler
    let destination: OverflowMenuDestination

    func dropUpdated(info: DropInfo) -> DropProposal? {
      return DropProposal(operation: .move)
    }

    /// Ends the current drag.
    func performDrop(info: DropInfo) -> Bool {
      // Any necessary state has already been updated, so just stop the current
      // drag.
      handler.endDrag()
      return true
    }

    /// Updates the state when the user's finger enters the given destination.
    func dropEntered(info: DropInfo) {
      // Moves the dragged destination to the position where the user's finger
      // is and updates the `destinationUsageEnabled` property if the order has
      // changed.
      guard let currentDrag = handler.currentDrag else {
        return
      }
      handler.dragOnDestinations = true
      guard
        let fromIndex = handler.destinationModel.shownDestinations.firstIndex(
          of: currentDrag.item)
      else {
        return
      }
      handler.destinationModel.destinationUsageEnabled =
        currentDrag.initialDestinationUsageEnabled && fromIndex == currentDrag.initialIndex
      guard let toIndex = handler.destinationModel.shownDestinations.firstIndex(of: destination)
      else {
        return
      }
      if fromIndex != toIndex {
        withAnimation {
          self.handler.destinationModel.shownDestinations.move(
            fromOffsets: IndexSet(integer: fromIndex),
            toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
        }
      }
    }
  }

  /// Drop delegate for the entire destination list. This allow making changes
  /// for if the user drags the destination out of the list.
  struct DestinationListDropDelegate: DropDelegate {
    let handler: DestinationDragHandler

    func dropUpdated(info: DropInfo) -> DropProposal? {
      return DropProposal(operation: .cancel)
    }

    /// Ends the current drag. Any necessary state has already been updated.
    func performDrop(info: DropInfo) -> Bool {
      handler.endDrag()
      return true
    }

    /// Updates the drag state for when the user's finger leaves the row.
    func dropExited(info: DropInfo) {
      // As the system does not notify the app when the drag directly ends, any
      // preparation for a drag ending in random space must be set up here.
      handler.dragOnDestinations = false
    }

    /// Updates the drag state for when the user's finger enters the row.
    func dropEntered(info: DropInfo) {
      // Set up the correct state for if a drag re-enters the destinations list.
      handler.dragOnDestinations = true
    }
  }
}

extension DestinationDragHandler: DidEndItemProviderDelegate {
  func didEndItemProviderDidEnd(_ provider: DidEndItemProvider) {
    providers = providers.filter { container in
      container.provider != nil && container.provider != provider
    }
    if providers.isEmpty {
      endDrag()
    }
  }
}

protocol DidEndItemProviderDelegate: AnyObject {
  /// Alerts the delegate when the item provider is being deinitialized.
  func didEndItemProviderDidEnd(_ provider: DidEndItemProvider)
}

/// A custom item provider class that alerts its delegate when it is about to
/// be de-initialized.
class DidEndItemProvider: NSItemProvider {
  weak var delegate: DidEndItemProviderDelegate?
  deinit {
    delegate?.didEndItemProviderDidEnd(self)
  }
}

/// Container class that holds a weak `DidEndItemProvider`, allowing for
/// collections to hold this weakly.
class WeakItemProviderContainer {
  weak var provider: DidEndItemProvider?
  init(provider: DidEndItemProvider?) {
    self.provider = provider
  }
}