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

290 lines
11 KiB
Swift
Raw Normal View History

2020-11-10 00:39:42 +00:00
//
// FastAccountSwitcherViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/4/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
2022-05-01 15:53:12 +00:00
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController)
/// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached.
2020-11-10 00:39:42 +00:00
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!
2022-05-01 15:53:12 +00:00
private(set) var accountViews: [FastSwitchingAccountView] = []
2020-11-10 00:39:42 +00:00
private var lastSelectedAccountViewIndex: Int?
2023-01-27 02:28:56 +00:00
private var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator?
2020-11-10 00:39:42 +00:00
private var touchBeganFeedbackWorkItem: DispatchWorkItem?
2022-05-01 15:53:12 +00:00
var itemOrientation: ItemOrientation = .iconsTrailing
2020-11-10 00:39:42 +00:00
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()
2022-05-01 15:53:12 +00:00
// 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
}
2020-11-10 00:39:42 +00:00
view.isHidden = false
2021-02-06 18:47:45 +00:00
if UIAccessibility.prefersCrossFadeTransitions {
2020-11-10 00:39:42 +00:00
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) {
2020-11-10 00:39:42 +00:00
accountView.alpha = 1
accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}
UIView.addKeyframe(withRelativeStartTime: relStart + relDuration / 2, relativeDuration: relDuration / 2) {
2020-11-10 00:39:42 +00:00
accountView.transform = .identity
}
}
}
}
}
2020-11-11 20:26:25 +00:00
func hide(completion: (() -> Void)? = nil) {
2022-05-01 15:53:12 +00:00
guard view.superview != nil else {
return
}
2020-11-10 00:39:42 +00:00
lastSelectedAccountViewIndex = nil
selectionChangedFeedbackGenerator = nil
2020-11-11 20:26:25 +00:00
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) {
2020-11-10 00:39:42 +00:00
self.view.alpha = 0
} completion: { (_) in
2022-05-01 15:53:12 +00:00
// todo: probably remove these two lines
2020-11-10 00:39:42 +00:00
self.view.alpha = 1
self.view.isHidden = true
2020-11-11 20:26:25 +00:00
completion?()
2022-05-01 15:53:12 +00:00
self.view.removeFromSuperview()
2020-11-10 00:39:42 +00:00
}
}
private func createAccountViews() {
accountsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
2022-05-01 15:53:12 +00:00
let addAccountPlaceholder = FastSwitchingAccountView(orientation: itemOrientation)
accountsStack.addArrangedSubview(addAccountPlaceholder)
accountViews = [
addAccountPlaceholder
]
2020-11-10 00:39:42 +00:00
for account in LocalData.shared.accounts {
2022-05-01 15:53:12 +00:00
let accountView = FastSwitchingAccountView(account: account, orientation: itemOrientation)
2020-11-10 00:39:42 +00:00
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
2020-11-10 00:39:42 +00:00
if hapticFeedback {
2023-01-27 02:28:56 +00:00
selectionChangedFeedbackGenerator?.selectionChanged()
2020-11-10 00:39:42 +00:00
}
selectionChangedFeedbackGenerator = nil
2020-11-11 20:26:25 +00:00
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
2020-11-11 20:26:25 +00:00
}
} else {
let account = LocalData.shared.accounts[newIndex - 1]
if account.id != LocalData.shared.mostRecentAccountID {
if hapticFeedback {
2023-01-27 02:28:56 +00:00
selectionChangedFeedbackGenerator?.selectionChanged()
}
selectionChangedFeedbackGenerator = nil
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).activateAccount(account, animated: true)
}
} else {
hide()
}
2020-11-10 00:39:42 +00:00
}
}
// MARK: - Interaction
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
switch recognizer.state {
case .began:
2023-01-27 02:28:56 +00:00
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
2020-11-10 00:39:42 +00:00
selectionChangedFeedbackGenerator?.prepare()
show()
case .changed:
let location = recognizer.location(in: view)
handleGestureMoved(to: location)
case .ended:
if let index = lastSelectedAccountViewIndex {
switchAccount(newIndex: index)
2022-05-01 15:53:12 +00:00
} else if !(delegate?.fastAccountSwitcher(self, triggerZoneContains: recognizer.location(in: recognizer.view)) ?? false) {
2020-11-10 00:39:42 +00:00
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 {
2023-01-27 02:28:56 +00:00
selectionChangedFeedbackGenerator?.selectionChanged()
2020-11-10 00:39:42 +00:00
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)
2020-11-11 20:26:25 +00:00
} else {
hide()
2020-11-10 00:39:42 +00:00
}
}
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 {
2023-01-27 02:28:56 +00:00
self.selectionChangedFeedbackGenerator?.selectionChanged()
2020-11-10 00:39:42 +00:00
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)
}
}
2022-05-01 15:53:12 +00:00
extension FastAccountSwitcherViewController {
enum ItemOrientation {
case iconsLeading
case iconsTrailing
}
}
2020-11-10 00:39:42 +00:00
extension FastAccountSwitcherViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
2022-05-01 15:53:12 +00:00
let point = gestureRecognizer.location(in: gestureRecognizer.view)
2020-11-10 00:39:42 +00:00
return delegate?.fastAccountSwitcher(self, triggerZoneContains: point) ?? false
}
}