// // StretchyMenuInteraction.swift // Reader // // Created by Shadowfacts on 1/11/22. // import UIKit struct StretchyMenuItem { let title: String let subtitle: String? let menu: UIMenu? let action: () -> Void init(title: String, subtitle: String? = nil, menu: UIMenu? = nil, action: @escaping () -> Void) { self.title = title self.subtitle = subtitle self.menu = menu self.action = action } } protocol StretchyMenuInteractionDelegate: AnyObject { func stretchyMenuTitle() -> String? func stretchyMenuItems() -> [StretchyMenuItem] } class StretchyMenuInteraction: NSObject, UIInteraction { weak var delegate: StretchyMenuInteractionDelegate? private(set) weak var view: UIView? = nil private let menuHintView = MenuHintView() fileprivate let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium) private var snapshot: UIView? private var menuView: MenuView? private let menuOpenThreshold: CGFloat = 100 fileprivate var isShowingMenu = false init(delegate: StretchyMenuInteractionDelegate) { self.delegate = delegate menuHintView.translatesAutoresizingMaskIntoConstraints = false } func willMove(to view: UIView?) { if self.view != nil { fatalError("removing StretchyMenuInteraction from view is unsupported") } } func didMove(to view: UIView?) { self.view = view guard let view = view else { return } let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized)) panRecognizer.delegate = self panRecognizer.allowedScrollTypesMask = [.continuous] view.addGestureRecognizer(panRecognizer) } private var prevTranslation: CGFloat = 0 @objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) { guard let view = view, !isShowingMenu, let delegate = delegate else { return } let prevTranslation = self.prevTranslation let translation = recognizer.translation(in: view) self.prevTranslation = translation.x switch recognizer.state { case .began: snapshot = view.snapshotView(afterScreenUpdates: false) let effectiveTranslation = 0.5 * max(0, -translation.x) snapshot!.transform = CGAffineTransform(translationX: -effectiveTranslation, y: 0) view.addSubview(snapshot!) view.insertSubview(menuHintView, belowSubview: snapshot!) menuHintView.backgroundColor = view.backgroundColor menuHintView.frame = CGRect(x: view.bounds.width - effectiveTranslation, y: 0, width: effectiveTranslation, height: view.bounds.height) menuHintView.updateForProgress(0, animate: false) feedbackGenerator.prepare() case .changed: let effectiveTranslation = 0.5 * max(0, -translation.x) snapshot!.transform = CGAffineTransform(translationX: -effectiveTranslation, y: 0) if -prevTranslation < menuOpenThreshold && -translation.x >= menuOpenThreshold { feedbackGenerator.impactOccurred() } menuHintView.frame = CGRect(x: view.bounds.width - effectiveTranslation, y: 0, width: effectiveTranslation, height: view.bounds.height) menuHintView.updateForProgress(-translation.x / menuOpenThreshold, animate: true) case .ended: UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { self.snapshot!.transform = .identity } completion: { _ in self.snapshot!.removeFromSuperview() self.menuHintView.removeFromSuperview() } if -translation.x > menuOpenThreshold { guard let rootView = view.window?.rootViewController?.view else { return } let menuView = MenuView(title: delegate.stretchyMenuTitle(), items: delegate.stretchyMenuItems(), owner: self) self.menuView = menuView menuView.translatesAutoresizingMaskIntoConstraints = false menuView.layer.zPosition = 102 rootView.addSubview(menuView) NSLayoutConstraint.activate([ menuView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), menuView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), menuView.topAnchor.constraint(equalTo: rootView.topAnchor), menuView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), ]) rootView.layoutIfNeeded() menuView.animateIn() isShowingMenu = true feedbackGenerator.prepare() } default: break } } func hideMenu(completion: (() -> Void)? = nil) { guard let menuView = menuView else { return } menuView.animateOut() { menuView.removeFromSuperview() self.isShowingMenu = false completion?() } } } extension StretchyMenuInteraction: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return otherGestureRecognizer.name == AppNavigationController.panRecognizerName } } private class MenuHintView: UIView { private let pill = UIView() private var progress: CGFloat = 0 private var animator: UIViewPropertyAnimator? init() { super.init(frame: .zero) pill.frame = CGRect(x: 0, y: 0, width: 5, height: 50) pill.backgroundColor = .systemGray pill.layer.cornerRadius = 2.5 addSubview(pill) pill.backgroundColor = .systemGray pill.frame.size.height = 50 } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() let width: CGFloat = 5 pill.frame.origin.x = bounds.midX - width / 2 pill.frame.origin.y = bounds.midY - pill.frame.height / 2 } func updateForProgress(_ progress: CGFloat, animate: Bool) { let oldCompleted = self.progress >= 1 let completed = progress >= 1 self.progress = progress if oldCompleted != completed { func updatePill() { pill.backgroundColor = completed ? .tintColor : .systemGray let height: CGFloat = completed ? 75 : 50 pill.frame.origin.y = bounds.midY - height / 2 pill.frame.size.height = height } animator?.stopAnimation(true) if animate { animator = UIViewPropertyAnimator(duration: 0.5, dampingRatio: 0.5, animations: updatePill) animator!.startAnimation() } else { updatePill() } } } } private class MenuView: UIView { weak var owner: StretchyMenuInteraction? private let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var blurView: UIView! private let optionsStack = UIStackView() private let items: [StretchyMenuItem] init(title: String?, items: [StretchyMenuItem], owner: StretchyMenuInteraction) { self.items = items self.owner = owner super.init(frame: .zero) blurView = UIVisualEffectView(effect: blurEffect) blurView.translatesAutoresizingMaskIntoConstraints = false addSubview(blurView) optionsStack.axis = .vertical optionsStack.alignment = .fill optionsStack.spacing = 2 optionsStack.translatesAutoresizingMaskIntoConstraints = false for item in items { optionsStack.addArrangedSubview(MenuItemView(item: item, owner: owner)) } addSubview(optionsStack) if let title = title { let titleLabel = UILabel() titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.text = title titleLabel.textAlignment = .right titleLabel.font = .preferredFont(forTextStyle: .title1) let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label)) vibrancyView.contentView.addSubview(titleLabel) optionsStack.insertArrangedSubview(vibrancyView, at: 0) NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: vibrancyView.contentView.leadingAnchor, multiplier: 1), vibrancyView.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: titleLabel.trailingAnchor, multiplier: 1), titleLabel.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor), titleLabel.bottomAnchor.constraint(equalTo: vibrancyView.contentView.bottomAnchor), ]) } NSLayoutConstraint.activate([ blurView.leadingAnchor.constraint(equalTo: leadingAnchor), blurView.trailingAnchor.constraint(equalTo: trailingAnchor), blurView.topAnchor.constraint(equalTo: topAnchor), blurView.bottomAnchor.constraint(equalTo: bottomAnchor), optionsStack.leadingAnchor.constraint(equalTo: leadingAnchor), optionsStack.trailingAnchor.constraint(equalTo: trailingAnchor), optionsStack.centerYAnchor.constraint(equalTo: centerYAnchor), ]) blurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blurTapped))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func animateIn() { blurView.layer.opacity = 0 let count = optionsStack.arrangedSubviews.count for (index, item) in optionsStack.arrangedSubviews.enumerated() { let multiplier = (1 + CGFloat(index) * 1 / CGFloat(count - 1)) item.transform = CGAffineTransform(translationX: bounds.width * multiplier, y: 0) } UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseInOut) { self.blurView.layer.opacity = 1 for item in self.optionsStack.arrangedSubviews { item.transform = .identity } } } func animateOut(completion: @escaping () -> Void) { UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseInOut) { self.blurView.layer.opacity = 0 let count = self.optionsStack.arrangedSubviews.count for (index, item) in self.optionsStack.arrangedSubviews.enumerated() { let multiplier = (1 + CGFloat(index) * 1 / CGFloat(count - 1)) item.transform = CGAffineTransform(translationX: self.bounds.width * -multiplier, y: 0) } } completion: { _ in completion() } } @objc private func blurTapped() { owner?.hideMenu() } } private class MenuItemView: UIView { weak var owner: StretchyMenuInteraction? private let item: StretchyMenuItem init(item: StretchyMenuItem, owner: StretchyMenuInteraction) { self.item = item self.owner = owner super.init(frame: .zero) backgroundColor = .appBackground let titleLabel = UILabel() titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.text = item.title titleLabel.textColor = .tintColor titleLabel.textAlignment = .right titleLabel.font = .preferredFont(forTextStyle: .title2) addSubview(titleLabel) NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 4), titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1), trailingAnchor.constraint(equalToSystemSpacingAfter: titleLabel.trailingAnchor, multiplier: 1), heightAnchor.constraint(greaterThanOrEqualToConstant: 44), ]) if let subtitle = item.subtitle { let subtitleLabel = UILabel() subtitleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.text = subtitle subtitleLabel.textColor = .secondaryLabel subtitleLabel.textAlignment = .right addSubview(subtitleLabel) NSLayoutConstraint.activate([ subtitleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1), trailingAnchor.constraint(equalToSystemSpacingAfter: subtitleLabel.trailingAnchor, multiplier: 1), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor), bottomAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 4), ]) } else { NSLayoutConstraint.activate([ bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), ]) } if item.menu != nil { addInteraction(UIContextMenuInteraction(delegate: self)) } addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(itemTapped))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func itemTapped() { owner?.feedbackGenerator.impactOccurred() UIView.animateKeyframes(withDuration: 0.15, delay: 0, options: []) { UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { self.backgroundColor = .appSecondaryBackground } UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { self.backgroundColor = .appBackground } } completion: { _ in self.owner?.hideMenu() { self.item.action() } } } func addBorders(top: Bool, bottom: Bool) { if top { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .systemGray addSubview(view) NSLayoutConstraint.activate([ view.heightAnchor.constraint(equalToConstant: 1), view.topAnchor.constraint(equalTo: topAnchor), view.leadingAnchor.constraint(equalTo: leadingAnchor), view.trailingAnchor.constraint(equalTo: trailingAnchor), ]) } if bottom { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .systemGray addSubview(view) NSLayoutConstraint.activate([ view.heightAnchor.constraint(equalToConstant: 1), view.bottomAnchor.constraint(equalTo: bottomAnchor), view.leadingAnchor.constraint(equalTo: leadingAnchor), view.trailingAnchor.constraint(equalTo: trailingAnchor), ]) } } } extension MenuItemView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(actionProvider: { [unowned self] _ in return self.item.menu! }) } }