From 503d35f3012e05b4f9c59996b34376659b6e9627 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 12 Jan 2022 11:36:12 -0500 Subject: [PATCH] Add stretchy menus --- Reader.xcodeproj/project.pbxproj | 4 + Reader/Screens/AppNavigationController.swift | 3 + Reader/Screens/Home/HomeViewController.swift | 20 + Reader/Screens/Read/ReadViewController.swift | 24 ++ Reader/StretchyMenuInteraction.swift | 405 +++++++++++++++++++ Reader/UIColor+App.swift | 11 + 6 files changed, 467 insertions(+) create mode 100644 Reader/StretchyMenuInteraction.swift diff --git a/Reader.xcodeproj/project.pbxproj b/Reader.xcodeproj/project.pbxproj index 5f23ad2..1a32989 100644 --- a/Reader.xcodeproj/project.pbxproj +++ b/Reader.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D6E24370278BE1250005E546 /* HTMLEntities */; }; D6E24373278BE2B80005E546 /* read.css in Resources */ = {isa = PBXBuildFile; fileRef = D6E24372278BE2B80005E546 /* read.css */; }; D6EB531D278C89C300AD2E61 /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */; }; + D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -131,6 +132,7 @@ D6E2436D278BD8160005E546 /* ReadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadViewController.swift; sourceTree = ""; }; D6E24372278BE2B80005E546 /* read.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = read.css; sourceTree = ""; }; D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = ""; }; + D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StretchyMenuInteraction.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -242,6 +244,7 @@ D65B18B527504920004A9448 /* FervorController.swift */, D65B18BD275051A1004A9448 /* LocalData.swift */, D6E24368278BABB40005E546 /* UIColor+App.swift */, + D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */, D6A8A33527766E9300CCEC72 /* CoreData */, D65B18AF2750468B004A9448 /* Screens */, D6C687F7272CD27700874C10 /* Assets.xcassets */, @@ -499,6 +502,7 @@ D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */, D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */, D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */, + D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */, D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */, D65B18BE275051A1004A9448 /* LocalData.swift in Sources */, D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */, diff --git a/Reader/Screens/AppNavigationController.swift b/Reader/Screens/AppNavigationController.swift index 19eea30..da509f8 100644 --- a/Reader/Screens/AppNavigationController.swift +++ b/Reader/Screens/AppNavigationController.swift @@ -10,6 +10,8 @@ import UIKit class AppNavigationController: UINavigationController, UINavigationControllerDelegate { private var statusBarBlockingView: UIView! + + static let panRecognizerName = "AppNavPanRecognizer" override func viewDidLoad() { super.viewDidLoad() @@ -21,6 +23,7 @@ class AppNavigationController: UINavigationController, UINavigationControllerDel interactivePopGestureRecognizer?.isEnabled = false let recognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized)) recognizer.allowedScrollTypesMask = .continuous + recognizer.name = AppNavigationController.panRecognizerName view.addGestureRecognizer(recognizer) isNavigationBarHidden = true diff --git a/Reader/Screens/Home/HomeViewController.swift b/Reader/Screens/Home/HomeViewController.swift index a15319d..0f0ad9b 100644 --- a/Reader/Screens/Home/HomeViewController.swift +++ b/Reader/Screens/Home/HomeViewController.swift @@ -33,6 +33,10 @@ class HomeViewController: UIViewController { // todo: account info title = "Reader" + view.addInteraction(StretchyMenuInteraction(delegate: self)) + + view.backgroundColor = .appBackground + var config = UICollectionLayoutListConfiguration(appearance: .grouped) config.headerMode = .supplementary config.backgroundColor = .appBackground @@ -236,3 +240,19 @@ extension HomeViewController: UICollectionViewDelegate { } } } + +extension HomeViewController: StretchyMenuInteractionDelegate { + func stretchyMenuTitle() -> String? { + return "Switch Accounts" + } + func stretchyMenuItems() -> [StretchyMenuItem] { + return [ + StretchyMenuItem(title: "foo", subtitle: "bar", action: { + print("foo") + }), + StretchyMenuItem(title: "baz", subtitle: "qux", action: { + print("baz") + }), + ] + } +} diff --git a/Reader/Screens/Read/ReadViewController.swift b/Reader/Screens/Read/ReadViewController.swift index c062f89..dc489c0 100644 --- a/Reader/Screens/Read/ReadViewController.swift +++ b/Reader/Screens/Read/ReadViewController.swift @@ -47,6 +47,7 @@ class ReadViewController: UIViewController { navigationItem.largeTitleDisplayMode = .never view.backgroundColor = .appBackground + view.addInteraction(StretchyMenuInteraction(delegate: self)) let webView = WKWebView() webView.translatesAutoresizingMaskIntoConstraints = false @@ -167,3 +168,26 @@ extension ReadViewController: WKUIDelegate { } } } + +extension ReadViewController: StretchyMenuInteractionDelegate { + func stretchyMenuTitle() -> String? { + return nil + } + + func stretchyMenuItems() -> [StretchyMenuItem] { + guard let url = item.url else { + return [] + } + return [ + StretchyMenuItem(title: "Open in Safari", subtitle: nil, action: { [unowned self] in + self.present(createSafariVC(url: url), animated: true) + }), + StretchyMenuItem(title: "Share", subtitle: nil, action: { [unowned self] in + self.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true) + }), + StretchyMenuItem(title: item.read ? "Mark as Unread" : "Mark as Read", subtitle: nil, action: { [unowned self] in + item.read = !item.read + }), + ] + } +} diff --git a/Reader/StretchyMenuInteraction.swift b/Reader/StretchyMenuInteraction.swift new file mode 100644 index 0000000..cc59283 --- /dev/null +++ b/Reader/StretchyMenuInteraction.swift @@ -0,0 +1,405 @@ +// +// 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 + 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), + ]) + } + } + +} diff --git a/Reader/UIColor+App.swift b/Reader/UIColor+App.swift index e75c377..2c57a45 100644 --- a/Reader/UIColor+App.swift +++ b/Reader/UIColor+App.swift @@ -20,6 +20,17 @@ extension UIColor { } } + static let appSecondaryBackground = UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + case .dark: + return UIColor(white: 0.2, alpha: 1) + case .unspecified, .light: + fallthrough + @unknown default: + return UIColor(white: 0.8, alpha: 1) + } + } + static let appCellHighlightBackground = UIColor { traitCollection in switch traitCollection.userInterfaceStyle { case .dark: