Add stretchy menus

This commit is contained in:
Shadowfacts 2022-01-12 11:36:12 -05:00
parent 8e6bf219c8
commit 503d35f301
6 changed files with 467 additions and 0 deletions

View File

@ -48,6 +48,7 @@
D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D6E24370278BE1250005E546 /* HTMLEntities */; }; D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D6E24370278BE1250005E546 /* HTMLEntities */; };
D6E24373278BE2B80005E546 /* read.css in Resources */ = {isa = PBXBuildFile; fileRef = D6E24372278BE2B80005E546 /* read.css */; }; D6E24373278BE2B80005E546 /* read.css in Resources */ = {isa = PBXBuildFile; fileRef = D6E24372278BE2B80005E546 /* read.css */; };
D6EB531D278C89C300AD2E61 /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -131,6 +132,7 @@
D6E2436D278BD8160005E546 /* ReadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadViewController.swift; sourceTree = "<group>"; }; D6E2436D278BD8160005E546 /* ReadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadViewController.swift; sourceTree = "<group>"; };
D6E24372278BE2B80005E546 /* read.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = read.css; sourceTree = "<group>"; }; D6E24372278BE2B80005E546 /* read.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = read.css; sourceTree = "<group>"; };
D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = "<group>"; }; D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = "<group>"; };
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StretchyMenuInteraction.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -242,6 +244,7 @@
D65B18B527504920004A9448 /* FervorController.swift */, D65B18B527504920004A9448 /* FervorController.swift */,
D65B18BD275051A1004A9448 /* LocalData.swift */, D65B18BD275051A1004A9448 /* LocalData.swift */,
D6E24368278BABB40005E546 /* UIColor+App.swift */, D6E24368278BABB40005E546 /* UIColor+App.swift */,
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */,
D6A8A33527766E9300CCEC72 /* CoreData */, D6A8A33527766E9300CCEC72 /* CoreData */,
D65B18AF2750468B004A9448 /* Screens */, D65B18AF2750468B004A9448 /* Screens */,
D6C687F7272CD27700874C10 /* Assets.xcassets */, D6C687F7272CD27700874C10 /* Assets.xcassets */,
@ -499,6 +502,7 @@
D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */, D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */,
D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */, D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */,
D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */, D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */,
D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */,
D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */, D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */,
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */, D65B18BE275051A1004A9448 /* LocalData.swift in Sources */,
D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */, D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */,

View File

@ -10,6 +10,8 @@ import UIKit
class AppNavigationController: UINavigationController, UINavigationControllerDelegate { class AppNavigationController: UINavigationController, UINavigationControllerDelegate {
private var statusBarBlockingView: UIView! private var statusBarBlockingView: UIView!
static let panRecognizerName = "AppNavPanRecognizer"
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -21,6 +23,7 @@ class AppNavigationController: UINavigationController, UINavigationControllerDel
interactivePopGestureRecognizer?.isEnabled = false interactivePopGestureRecognizer?.isEnabled = false
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized)) let recognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized))
recognizer.allowedScrollTypesMask = .continuous recognizer.allowedScrollTypesMask = .continuous
recognizer.name = AppNavigationController.panRecognizerName
view.addGestureRecognizer(recognizer) view.addGestureRecognizer(recognizer)
isNavigationBarHidden = true isNavigationBarHidden = true

View File

@ -33,6 +33,10 @@ class HomeViewController: UIViewController {
// todo: account info // todo: account info
title = "Reader" title = "Reader"
view.addInteraction(StretchyMenuInteraction(delegate: self))
view.backgroundColor = .appBackground
var config = UICollectionLayoutListConfiguration(appearance: .grouped) var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.headerMode = .supplementary config.headerMode = .supplementary
config.backgroundColor = .appBackground 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")
}),
]
}
}

View File

@ -47,6 +47,7 @@ class ReadViewController: UIViewController {
navigationItem.largeTitleDisplayMode = .never navigationItem.largeTitleDisplayMode = .never
view.backgroundColor = .appBackground view.backgroundColor = .appBackground
view.addInteraction(StretchyMenuInteraction(delegate: self))
let webView = WKWebView() let webView = WKWebView()
webView.translatesAutoresizingMaskIntoConstraints = false 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
}),
]
}
}

View File

@ -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),
])
}
}
}

View File

@ -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 static let appCellHighlightBackground = UIColor { traitCollection in
switch traitCollection.userInterfaceStyle { switch traitCollection.userInterfaceStyle {
case .dark: case .dark: