Tusker/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewCont...

290 lines
11 KiB
Swift

//
// FastAccountSwitcherViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/4/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController)
/// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached.
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool
}
class FastAccountSwitcherViewController: UIViewController {
weak var delegate: FastAccountSwitcherViewControllerDelegate?
@IBOutlet weak var dimmingView: UIView!
@IBOutlet weak var blurContentView: UIView!
@IBOutlet weak var accountsStack: UIStackView!
private(set) var accountViews: [FastSwitchingAccountView] = []
private var lastSelectedAccountViewIndex: Int?
private var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator?
private var touchBeganFeedbackWorkItem: DispatchWorkItem?
var itemOrientation: ItemOrientation = .iconsTrailing
init() {
super.init(nibName: "FastAccountSwitcherViewController", bundle: .main)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.isHidden = true
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))))
accountsStack.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))))
}
func createSwitcherGesture() -> UIGestureRecognizer {
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
recognizer.delegate = self
recognizer.minimumPressDuration = 0.25
return recognizer
}
func show() {
createAccountViews()
// add after creating account views so that the presenter can align based on them
delegate?.fastAccountSwitcherAddToViewHierarchy(self)
switch itemOrientation {
case .iconsLeading:
accountsStack.alignment = .leading
case .iconsTrailing:
accountsStack.alignment = .trailing
}
view.isHidden = false
if UIAccessibility.prefersCrossFadeTransitions {
view.alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.view.alpha = 1
}
} else {
let totalDuration: TimeInterval = 0.5
UIView.animateKeyframes(withDuration: totalDuration, delay: 0, options: .allowUserInteraction) {
self.view.alpha = 0
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
self.view.alpha = 1
}
for (index, accountView) in self.accountViews.reversed().enumerated() {
let relStart = 0.5 * Double(index) / Double(self.accountsStack.arrangedSubviews.count)
let relDuration = 0.5 * 2 / Double(self.accountsStack.arrangedSubviews.count)
accountView.alpha = 0
accountView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDuration / 2) {
accountView.alpha = 1
accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}
UIView.addKeyframe(withRelativeStartTime: relStart + relDuration / 2, relativeDuration: relDuration / 2) {
accountView.transform = .identity
}
}
}
}
}
func hide(completion: (() -> Void)? = nil) {
guard view.superview != nil else {
return
}
lastSelectedAccountViewIndex = nil
selectionChangedFeedbackGenerator = nil
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) {
self.view.alpha = 0
} completion: { (_) in
// todo: probably remove these two lines
self.view.alpha = 1
self.view.isHidden = true
completion?()
self.view.removeFromSuperview()
}
}
private func createAccountViews() {
accountsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let addAccountPlaceholder = FastSwitchingAccountView(orientation: itemOrientation)
accountsStack.addArrangedSubview(addAccountPlaceholder)
accountViews = [
addAccountPlaceholder
]
for account in LocalData.shared.accounts {
let accountView = FastSwitchingAccountView(account: account, orientation: itemOrientation)
accountView.isCurrent = account.id == LocalData.shared.mostRecentAccountID
accountsStack.addArrangedSubview(accountView)
accountViews.append(accountView)
}
}
private func accountViewIndex(at point: CGPoint) -> Int? {
for (index, accountView) in accountViews.enumerated() {
let pointInAccountView = accountView.convert(point, from: view)
if accountView.bounds.contains(pointInAccountView) {
return index
}
}
return nil
}
private func switchAccount(newIndex: Int, hapticFeedback: Bool = true) {
if newIndex == 0 { // add account placeholder
if hapticFeedback {
selectionChangedFeedbackGenerator?.selectionChanged()
}
selectionChangedFeedbackGenerator = nil
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
}
} else {
let account = LocalData.shared.accounts[newIndex - 1]
if account.id != LocalData.shared.mostRecentAccountID {
if hapticFeedback {
selectionChangedFeedbackGenerator?.selectionChanged()
}
selectionChangedFeedbackGenerator = nil
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).activateAccount(account, animated: true)
}
} else {
hide()
}
}
}
// MARK: - Interaction
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
switch recognizer.state {
case .began:
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
selectionChangedFeedbackGenerator?.prepare()
show()
case .changed:
let location = recognizer.location(in: view)
handleGestureMoved(to: location)
case .ended:
if let index = lastSelectedAccountViewIndex {
switchAccount(newIndex: index)
} else if !(delegate?.fastAccountSwitcher(self, triggerZoneContains: recognizer.location(in: recognizer.view)) ?? false) {
hide()
}
default:
break
}
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .changed:
let location = recognizer.location(in: view)
handleGestureMoved(to: location)
case .ended:
if let index = lastSelectedAccountViewIndex {
switchAccount(newIndex: index)
}
default:
break
}
}
private func handleGestureMoved(to location: CGPoint, hapticFeedback: Bool = true) {
let selectedAccountViewIndex = accountViewIndex(at: location)
if selectedAccountViewIndex != lastSelectedAccountViewIndex {
if let lastSelected = lastSelectedAccountViewIndex {
accountViews[lastSelected].isSelected = false
}
if let newSelected = selectedAccountViewIndex {
accountViews[newSelected].isSelected = true
}
lastSelectedAccountViewIndex = selectedAccountViewIndex
if hapticFeedback {
selectionChangedFeedbackGenerator?.selectionChanged()
selectionChangedFeedbackGenerator?.prepare()
}
}
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
if let tappedIndex = accountViewIndex(at: recognizer.location(in: view)) {
// cancel the selection-changed feedback initiated in touchesBegan so that,
// upon switching, we don't trigger a double feedback
touchBeganFeedbackWorkItem?.cancel()
touchBeganFeedbackWorkItem = nil
switchAccount(newIndex: tappedIndex)
} else {
hide()
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if touches.count == 1,
let touch = touches.first,
accountsStack.bounds.contains(touch.location(in: accountsStack)) {
handleGestureMoved(to: touch.location(in: view), hapticFeedback: false)
// don't trigger the haptic feedback immedaitely
// if the user is merely tapping, not initiating a pan, we don't want to trigger a double-impact
// if the tap ends very quickly, this will be cancelled
touchBeganFeedbackWorkItem = DispatchWorkItem {
self.selectionChangedFeedbackGenerator?.selectionChanged()
self.selectionChangedFeedbackGenerator?.prepare()
self.touchBeganFeedbackWorkItem = nil
}
// 100ms determined experimentally to be fast enough that there's not a hugely-perceivable delay when beginning a pan gesture
// and slow enough that it's longer than most reasonable-speed taps
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100), execute: touchBeganFeedbackWorkItem!)
}
super.touchesBegan(touches, with: event)
}
}
extension FastAccountSwitcherViewController {
enum ItemOrientation {
case iconsLeading
case iconsTrailing
}
}
extension FastAccountSwitcherViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let point = gestureRecognizer.location(in: gestureRecognizer.view)
return delegate?.fastAccountSwitcher(self, triggerZoneContains: point) ?? false
}
}