428 lines
16 KiB
Swift
428 lines
16 KiB
Swift
//
|
|
// 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))
|
|
}
|
|
|
|
addInteraction(UIPointerInteraction())
|
|
|
|
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!
|
|
})
|
|
}
|
|
}
|