frenzy-ios/Reader/StretchyMenuInteraction.swift

406 lines
15 KiB
Swift

//
// StretchyMenuInteraction.swift
// Reader
//
// Created by Shadowfacts on 1/11/22.
//
import UIKit
struct StretchyMenuItem {
let title: String
let subtitle: String?
let action: () -> Void
}
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),
])
}
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),
])
}
}
}