forked from shadowfacts/Tusker
290 lines
11 KiB
Swift
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: UIImpactFeedbackGenerator?
|
|
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?.impactOccurred()
|
|
}
|
|
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?.impactOccurred()
|
|
}
|
|
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:
|
|
selectionChangedFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
|
selectionChangedFeedbackGenerator?.impactOccurred()
|
|
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?.impactOccurred(intensity: 0.5)
|
|
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?.impactOccurred(intensity: 0.5)
|
|
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
|
|
}
|
|
}
|