Untitled
unknown
swift
2 years ago
20 kB
11
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