Path: blob/main/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuConfiguration.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
public import UIKit
public typealias ContextMenuActionHandler = (ContextMenuAction) -> Void
// UIAction analog
public class ContextMenuAction {
public struct Attributes: OptionSet {
public let rawValue: UInt
public init(rawValue: UInt) {
self.rawValue = rawValue
}
public static let disabled = ContextMenuAction.Attributes(rawValue: 1 << 0)
public static let destructive = ContextMenuAction.Attributes(rawValue: 1 << 1)
}
public let title: String
public let image: UIImage?
public let attributes: Attributes
public let handler: ContextMenuActionHandler
public init(
title: String = "",
image: UIImage? = nil,
attributes: ContextMenuAction.Attributes = [],
handler: @escaping ContextMenuActionHandler,
) {
self.title = title
self.image = image
self.attributes = attributes
self.handler = handler
}
}
/// UIMenu analog, supports single depth menus only
public class ContextMenu {
public let children: [ContextMenuAction]
public init(
_ children: [ContextMenuAction],
) {
self.children = children
}
}
protocol ContextMenuTargetedPreviewAccessoryInteractionDelegate: AnyObject {
func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory, completion: @escaping () -> Void)
func contextMenuTargetedPreviewAccessoryPreviewAlignment(_ accessory: ContextMenuTargetedPreviewAccessory) -> ContextMenuTargetedPreview.Alignment
func contextMenuTargetedPreviewAccessoryRequestsEmojiPicker(
for message: TSMessage,
accessory: ContextMenuTargetedPreviewAccessory,
completion: @escaping (String) -> Void,
)
}
/// Encapsulates an accessory view with relevant layout information
public class ContextMenuTargetedPreviewAccessory {
public struct AccessoryAlignment {
public enum Edge {
case top
case trailing
case leading
case bottom
}
public enum Origin {
case interior
case exterior
}
/// Accessory frame edge alignment relative to preview frame. Processed
/// in-order.
let alignments: [(Edge, Origin)]
/// An offset for the accessory frame relative to the preview frame.
/// Positive values result in an adjustment away from the center of the
/// preview frame, and negative values in an adjustment towards the
/// center of the preview frame.
///
/// - Note
/// This offset must not offset the accessory more than halfway across
/// the preview view, either vertically or horizontally, from any of the
/// edges in ``alignments``. Rather than offsetting that far, we should
/// be aligning to the opposite edge.
let alignmentOffset: CGPoint
}
/// Accessory view
var accessoryView: UIView
// Defines accessory layout relative to preview view
var accessoryAlignment: AccessoryAlignment
var landscapeAccessoryAlignment: AccessoryAlignment?
var animateAccessoryPresentationAlongsidePreview: Bool = false
var targetAnimateOutFrame: CGRect?
weak var delegate: ContextMenuTargetedPreviewAccessoryInteractionDelegate?
init(
accessoryView: UIView,
accessoryAlignment: AccessoryAlignment,
) {
self.accessoryView = accessoryView
self.accessoryAlignment = accessoryAlignment
}
func animateIn(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void,
) {
completion()
}
func animateOut(
duration: TimeInterval,
previewWillShift: Bool,
completion: @escaping () -> Void,
) {
completion()
}
/// Called when a current touch event changed location
/// - Parameter locationInView: location relative to accessoryView's coordinate space
func touchLocationInViewDidChange(locationInView: CGPoint) {
}
/// Called when a current touch event ended
/// - Parameter locationInView: location relative to accessoryView's coordinate space
/// - Returns: true if accessory handled the touch ending, false if the touch is not relevant to this view
func touchLocationInViewDidEnd(locationInView: CGPoint) -> Bool {
return false
}
}
// UITargetedPreview analog
// Supports snapshotting from target view only, and animating to/from the same target position
// View must be in a window when ContextMenuTargetedPreview is initialized
public class ContextMenuTargetedPreview {
public enum Alignment {
case left
case center
case right
}
public let view: UIView
public var auxiliaryView: UIView? {
didSet {
if let auxView = auxiliaryView {
if let snapshot = auxView.snapshotView(afterScreenUpdates: false) {
self.auxiliarySnapshot = snapshot
}
}
}
}
public let previewView: UIView
public let previewViewSourceFrame: CGRect
public var auxiliarySnapshot: UIView?
/// The horizontal edge to which accessory views should be aligned.
/// - Note
/// RTL-aware previews should set this as appropriate for the current RTL
/// state.
public let alignment: Alignment
public var alignmentOffset: CGPoint?
public let accessoryViews: [ContextMenuTargetedPreviewAccessory]
/// Default targeted preview initializer
/// View must be in a window
/// - Parameters:
/// - view: View to render preview of
/// - alignment: If preview needs to be scaled, this property defines the edge alignment
/// in the source view to pin the preview to
/// - accessoryViews: accessory view
public convenience init?(
view: UIView,
alignment: Alignment,
accessoryViews: [ContextMenuTargetedPreviewAccessory]?,
) {
AssertIsOnMainThread()
owsAssertDebug(view.window != nil, "View must be in a window")
guard let snapshot = view.snapshotView(afterScreenUpdates: false) else {
owsFailDebug("Unable to snapshot context menu preview view")
return nil
}
self.init(
view: view,
previewView: snapshot,
previewViewSourceFrame: view.frame,
alignment: alignment,
accessoryViews: accessoryViews ?? [],
)
}
/// Initialize using a custom preview view that may or may not originate from `view`
/// - Parameters:
/// - view: View to render a preview from
/// - previewView: The preview to render, this should be an unowned view that does not live an any hierarchies.
/// - previewViewSourceFrame: The frame to use as an initial and final rendering point for the `previewView`. This should be in the same coordinate space as `view`. If not provided the frame of `previewView` is used.
/// - alignment: If preview needs to be scaled, this property defines the edge alignment
/// in the source view to pin the preview to
/// - accessoryViews: accessory view
public init(
view: UIView,
previewView: UIView,
previewViewSourceFrame: CGRect? = nil,
alignment: Alignment,
accessoryViews: [ContextMenuTargetedPreviewAccessory],
) {
self.view = view
self.previewView = previewView
self.previewViewSourceFrame = previewViewSourceFrame ?? previewView.frame
self.alignment = alignment
self.accessoryViews = accessoryViews
}
}
public typealias ContextMenuActionProvider = ([ContextMenuAction]) -> ContextMenu?
// UIContextMenuConfiguration analog
public class ContextMenuConfiguration {
public let identifier: NSCopying
public let actionProvider: ContextMenuActionProvider?
public let forceDarkTheme: Bool
public init(
identifier: NSCopying?,
forceDarkTheme: Bool = false,
actionProvider: ContextMenuActionProvider?,
) {
if let ident = identifier {
self.identifier = ident
} else {
self.identifier = UUID() as NSCopying
}
self.forceDarkTheme = forceDarkTheme
self.actionProvider = actionProvider
}
}