// // 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, 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 } }