Untitled

 avatar
unknown
swift
a year ago
20 kB
8
Indexable
final class ContextMenuInteraction: NSObject, UIInteraction {
    
    private var controller: _ContextMenuController?
    
    weak var delegate: (any ContextMenuInteractionDelegate)?
    
    private(set) weak var view: UIView?
    
    func willMove(to view: UIView?) {
        guard view == nil else { return }
        controller = nil
        delegate = nil
        self.view = nil
    }
    
    func didMove(to view: UIView?) {
        self.view = view
        guard let view, let delegate else { return }
        let configuration = delegate.contextMenuInteraction(self, configurationForMenuAtLocation: .zero)
        let menu = configuration.actionProvider()
        self.controller = _ContextMenuController(menu, for: view)
    }
    
    init(delegate: any ContextMenuInteractionDelegate) {
        self.delegate = delegate
    }
}

protocol ContextMenuInteractionDelegate: AnyObject {
    
    func contextMenuInteraction(_ interaction: ContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> ContextMenuConfiguration
}

struct ContextMenuConfiguration {
    
    typealias ActionProvider = () -> UIMenu
    
    fileprivate let actionProvider: ActionProvider
    
    init(actionProvider: @escaping ActionProvider) {
        self.actionProvider = actionProvider
    }
}

fileprivate final class _ContextMenuController {
    
    private unowned let targetView: UIView
    
    private let menu: UIMenu
    
    private var containerView: _ContextMenuContainerView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(_ContextMenuContainerView(frame: .zero))
    
    init(_ menu: UIMenu, for targetView: UIView) {
        self.menu = menu
        self.targetView = targetView
        guard let window = targetView.window else { return }
        targetView.alpha = 0.0
        containerView.transitionView.targetSnapshotView = targetView.snapshotView(afterScreenUpdates: false)
        containerView.transitionView.menuView.menu = menu
        window.addSubview(containerView)
        NSLayoutConstraint.activate([
            containerView.topAnchor.constraint(equalTo: window.topAnchor),
            containerView.leadingAnchor.constraint(equalTo: window.leadingAnchor),
            window.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
            window.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
        ])
    }
}

fileprivate final class _ContextMenuContainerView: UIView {
    
    // [blurFilter setValue:@10 forKey:@"inputRadius"];
    private let backdropEffectView: UIVisualEffectView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(UIVisualEffectView(effect: nil))
    
    let transitionView: _ContextMenuTransitionView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(_ContextMenuTransitionView(frame: .zero))
    
    override func didMoveToWindow() {
        super.didMoveToWindow()
        transitionView.animateIn()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(backdropEffectView)
        addSubview(transitionView)
        NSLayoutConstraint.activate([
            backdropEffectView.topAnchor.constraint(equalTo: topAnchor),
            backdropEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
            trailingAnchor.constraint(equalTo: backdropEffectView.trailingAnchor),
            bottomAnchor.constraint(equalTo: backdropEffectView.bottomAnchor),
            
            transitionView.topAnchor.constraint(equalTo: topAnchor),
            transitionView.leadingAnchor.constraint(equalTo: leadingAnchor),
            trailingAnchor.constraint(equalTo: transitionView.trailingAnchor),
            bottomAnchor.constraint(equalTo: transitionView.bottomAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

fileprivate final class _ContextMenuTransitionView: UIView {
    
    var targetSnapshotView: UIView?
    
    let menuView: _ContextMenuView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        $0.alpha = 0.0
        return $0
    }(_ContextMenuView(frame: .zero))
    
    func animateIn() {
        guard let targetSnapshotView else { return }
        //targetSnapshotView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(targetSnapshotView)
        addSubview(menuView)
        NSLayoutConstraint.activate([
            menuView.centerXAnchor.constraint(equalTo: centerXAnchor),
            menuView.centerYAnchor.constraint(equalTo: centerYAnchor),
            menuView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6),
        ])
        menuView.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
        
        let animator = UIViewPropertyAnimator(duration: 0.0, timingParameters: UISpringTimingParameters(dampingRatio: 0.7, frequencyResponse: 0.4))
        animator.addAnimations { [self] in
            menuView.alpha = 1.0
            menuView.transform = .identity
        }
        animator.startAnimation()
    }
    
    func animateOut() {
        
    }
}

fileprivate final class _ContextMenuView: UIView {
    
    var menu: UIMenu? {
        didSet {
            listView.displayedMenu = menu
        }
    }
    
    private let listView: _ContextMenuListView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(_ContextMenuListView(frame: .zero))
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(listView)
        NSLayoutConstraint.activate([
            listView.topAnchor.constraint(equalTo: topAnchor),
            listView.leadingAnchor.constraint(equalTo: leadingAnchor),
            trailingAnchor.constraint(equalTo: listView.trailingAnchor),
            bottomAnchor.constraint(equalTo: listView.bottomAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

fileprivate final class _ContextMenuListView: UIView {
    
    var displayedMenu: UIMenu? {
        didSet {
            var snapshot = NSDiffableDataSourceSnapshot<Int, ObjectIdentifier>()
            if let displayedMenu {
                snapshot.appendSections([0])
                snapshot.appendItems(displayedMenu.children.lazy.map { ObjectIdentifier($0) })
            }
            collectionDataSource.applySnapshotUsingReloadData(snapshot)
        }
    }
    
    private let cutoutShadowView: _ContextMenuCutoutShadowView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        let shadow = NSShadow()
        shadow.shadowOffset = .zero
        shadow.shadowBlurRadius = 16.0
        shadow.shadowColor = UIColor.black.withAlphaComponent(0.3)
        $0.shadow = shadow
        $0.cornerRadius = 13.0
        return $0
    }(_ContextMenuCutoutShadowView(frame: .zero))
    
    private let contentView: UIView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        $0.clipsToBounds = true
        $0.layer.cornerCurve = .continuous
        $0.layer.cornerRadius = 13.0
        $0.layer.allowsEdgeAntialiasing = true
        $0.layer.allowsGroupOpacity = true
        return $0
    }(UIView(frame: .zero))
    
    private let backdropEffectView: UIVisualEffectView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        $0.isUserInteractionEnabled = false
        return $0
    }(UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)))
    
    private let collectionView: _ContextMenuListCollectionView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        return $0
    }(_ContextMenuListCollectionView(frame: .zero))
    
    private lazy var collectionDataSource = makeCollectionDatSource()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(cutoutShadowView)
        addSubview(contentView)
        contentView.addSubview(backdropEffectView)
        contentView.addSubview(collectionView)
        NSLayoutConstraint.activate([
            cutoutShadowView.widthAnchor.constraint(equalTo: widthAnchor).priority(.defaultHigh),
            cutoutShadowView.heightAnchor.constraint(equalTo: heightAnchor).priority(.defaultHigh),
            cutoutShadowView.centerXAnchor.constraint(equalTo: centerXAnchor),
            cutoutShadowView.centerYAnchor.constraint(equalTo: centerYAnchor),
            
            contentView.topAnchor.constraint(equalTo: topAnchor),
            contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
            trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            
            backdropEffectView.topAnchor.constraint(equalTo: contentView.topAnchor),
            backdropEffectView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: backdropEffectView.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: backdropEffectView.bottomAnchor),
            
            collectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private func makeCollectionDatSource() -> UICollectionViewDiffableDataSource<Int, ObjectIdentifier> {
        let cellRegistration = makeCollectionCellRegistration()
        let supplementaryRegistration = makeCollectionSeparatorSupplementaryRegistration()
        return .init(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        } supplementaryViewProvider: { collectionView, elementKind, indexPath in
            collectionView.dequeueConfiguredReusableSupplementary(using: supplementaryRegistration, for: indexPath)
        }
    }
    
    private func makeCollectionCellRegistration() -> UICollectionView.CellRegistration<_ContextMenuCollectionViewCell, ObjectIdentifier> {
        return .init { [unowned self] cell, indexPath, itemIdentifier in
            guard let menuElement = displayedMenu?.children.first(where: { ObjectIdentifier($0) == itemIdentifier }) else { preconditionFailure() }
            cell.configureWith(title: menuElement.title, image: menuElement.image)
        }
    }
    
    private func makeCollectionSeparatorSupplementaryRegistration() -> UICollectionView.SupplementaryRegistration<_ContextMenuSeparatorCollectionReusableView> {
        return .init(elementKind: _ContextMenuListCollectionView.elementKindItemSeparator) { [unowned self] supplementaryView, elementKind, indexPath in
            supplementaryView.isHidden = indexPath.item == displayedMenu?.children.index(displayedMenu!.children.endIndex, offsetBy: -1, limitedBy: 0) ? true : false
        }
    }
}

fileprivate final class _ContextMenuCutoutShadowView: UIImageView {
    
    var shadow = NSShadow() {
        didSet {
            deferredUpdateShadowImage()
        }
    }
    
    var cornerRadius: CGFloat = 0.0 {
        didSet {
            deferredUpdateShadowImage()
        }
    }
    
    override var intrinsicContentSize: CGSize {
        CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
    }
    
    private var oldBounds: CGRect = .zero
    
    override func layoutSubviews() {
        super.layoutSubviews()
        if oldBounds != bounds {
            oldBounds = bounds
            updateShadowImage()
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        isUserInteractionEnabled = false
        contentMode = .center
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private func deferredUpdateShadowImage() {
        RunLoop.main.cancelPerform(#selector(updateShadowImage), target: self, argument: nil)
        RunLoop.main.perform(#selector(updateShadowImage), target: self, argument: nil, order: 0, modes: [.common])
    }
    
    @objc
    private func updateShadowImage() {
        image = UIGraphicsImageRenderer(
            size: CGSize(width: bounds.width + shadow.shadowBlurRadius * 2.0, height: bounds.height + shadow.shadowBlurRadius * 2.0),
            format: .preferred()
        ).image { context in
            let cgContext = context.cgContext
            
            let roundedRect = CGRect(x: shadow.shadowBlurRadius, y: shadow.shadowBlurRadius, width: bounds.width, height: bounds.height)
            let cgShadowPath = UIBezierPath(roundedRect: roundedRect, cornerRadius: cornerRadius).cgPath
            
            cgContext.addRect(cgContext.boundingBoxOfClipPath)
            cgContext.addPath(cgShadowPath)
            cgContext.clip(using: .evenOdd)
            
            let color = (shadow.shadowColor as! UIColor).cgColor
            cgContext.setStrokeColor(color)
            cgContext.addPath(cgShadowPath)
            cgContext.setShadow(offset: shadow.shadowOffset, blur: shadow.shadowBlurRadius, color: color)
            cgContext.fillPath()
        }
    }
}

fileprivate final class _ContextMenuListCollectionView: UICollectionView {
    
    let settedCollectionViewLayout = _ContextMenuListCollectionView.makeCollectionViewLayout()
    
    override var contentSize: CGSize {
        didSet {
            invalidateIntrinsicContentSize()
        }
    }
    
    override var intrinsicContentSize: CGSize {
        CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height == 0.0 ? 44.0 : contentSize.height)
    }
    
    init(frame: CGRect) {
        super.init(frame: frame, collectionViewLayout: settedCollectionViewLayout)
        backgroundColor = .clear
        delaysContentTouches = true
        isScrollEnabled = false
        showsHorizontalScrollIndicator = false
        showsVerticalScrollIndicator = false
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private static func makeCollectionViewLayout() -> UICollectionViewCompositionalLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
        let supplementaryItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1.0 / UIScreen.main.scale))
        let supplementaryItem = NSCollectionLayoutSupplementaryItem(layoutSize: supplementaryItemSize, elementKind: elementKindItemSeparator, containerAnchor: NSCollectionLayoutAnchor(edges: .bottom, absoluteOffset: CGPoint(x: 0.0, y: 1.0 / UIScreen.main.scale)))
        let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [supplementaryItem])
        item.contentInsets = NSDirectionalEdgeInsets(top: 0.0, leading: 0.0, bottom: 1.0 / UIScreen.main.scale, trailing: 0.0)
        let groupSize =  NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44.0))
        let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, repeatingSubitem: item, count: 1)
        let section = NSCollectionLayoutSection(group: group)
        return UICollectionViewCompositionalLayout(section: section)
    }
    
    static let elementKindItemSeparator = "CollectionElementKindItemSeparator"
}

fileprivate final class _ContextMenuCollectionViewCell: UICollectionViewCell {
    
    private let highlightVisualEffectView = UIVisualEffectView(effect: nil)
    
    private let titleLabel: UILabel = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        $0.textColor = .label
        $0.font = .preferredFont(forTextStyle: .body)
        $0.numberOfLines = 0
        return $0
    }(UILabel(frame: .zero))
    
    private let imageView: UIImageView = {
        $0.isUserInteractionEnabled = false
        $0.translatesAutoresizingMaskIntoConstraints = false
        $0.tintColor = .label
        return $0
    }(UIImageView(image: nil))
    
    override var isHighlighted: Bool {
        didSet {
            highlightVisualEffectView.contentView.backgroundColor = isHighlighted ? .black : nil
            highlightVisualEffectView.effect = isHighlighted ? UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemMaterial), style: .tertiaryFill) : nil
        }
    }
    
    func configureWith(title: String, image: UIImage?) {
        titleLabel.text = title
        imageView.image = image
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundView = highlightVisualEffectView
        contentView.addSubview(titleLabel)
        contentView.addSubview(imageView)
        
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12.0),
            titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16.0),
            contentView.bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12.0),
            imageView.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 16.0),
            
            imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
            imageView.heightAnchor.constraint(equalToConstant: 24.0),
            contentView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 16.0),
            imageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

fileprivate final class _ContextMenuSeparatorCollectionReusableView: UICollectionReusableView {
    
    private let contentVisualEffectView: UIVisualEffectView = {
        $0.translatesAutoresizingMaskIntoConstraints = false
        $0.isUserInteractionEnabled = false
        $0.contentView.backgroundColor = .black
        return $0
    }(UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemMaterial), style: .separator)))
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(contentVisualEffectView)
        NSLayoutConstraint.activate([
            contentVisualEffectView.topAnchor.constraint(equalTo: topAnchor),
            contentVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
            trailingAnchor.constraint(equalTo: contentVisualEffectView.trailingAnchor),
            bottomAnchor.constraint(equalTo: contentVisualEffectView.bottomAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

fileprivate extension UICollectionViewDiffableDataSource {
    
    convenience init(collectionView: UICollectionView, cellProvider: @escaping CellProvider, supplementaryViewProvider: @escaping SupplementaryViewProvider) {
        self.init(collectionView: collectionView, cellProvider: cellProvider)
        self.supplementaryViewProvider = supplementaryViewProvider
    }
}

fileprivate extension NSLayoutConstraint {
    
    func priority(_ newPriority: UILayoutPriority) -> NSLayoutConstraint {
        priority = newPriority
        return self
    }
}

fileprivate extension UISpringTimingParameters {
    
    convenience init(dampingRatio: CGFloat, frequencyResponse: CGFloat) {
        precondition(dampingRatio >= 0)
        precondition(frequencyResponse > 0)
        
        let mass = 1 as CGFloat
        let stiffness = pow(2 * .pi / frequencyResponse, 2.0) * mass
        let damping = 4.0 * .pi * dampingRatio * mass / frequencyResponse
        
        self.init(mass: mass, stiffness: stiffness, damping: damping, initialVelocity: .zero)
    }
}

fileprivate class LayerView<LayerType: CALayer>: UIView {
    
    override final class var layerClass: AnyClass { LayerType.self }
    
    final var settedLayer: LayerType { layer as! LayerType }
}
Editor is loading...
Leave a Comment