Add fast account switching on iPhone

This commit is contained in:
Shadowfacts 2020-11-09 19:39:42 -05:00
parent 348c306858
commit fc888b168c
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
8 changed files with 489 additions and 6 deletions

View File

@ -211,6 +211,9 @@
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; }; D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; }; D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */; }; D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */; };
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; };
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; }; D6A5BB2B23BAEF61003BF21D /* APIMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */; };
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; }; D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; }; D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
@ -556,6 +559,9 @@
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; }; D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; }; D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListTableViewController.swift; sourceTree = "<group>"; }; D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListTableViewController.swift; sourceTree = "<group>"; };
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = "<group>"; };
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = "<group>"; };
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = "<group>"; }; D6A5BB2A23BAEF61003BF21D /* APIMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMocks.swift; sourceTree = "<group>"; };
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; }; D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; }; D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
@ -932,6 +938,7 @@
D6F2E960249E772F005846BB /* Crash Reporter */, D6F2E960249E772F005846BB /* Crash Reporter */,
D641C782213DD7F0004B4513 /* Main */, D641C782213DD7F0004B4513 /* Main */,
D641C783213DD7FE004B4513 /* Onboarding */, D641C783213DD7FE004B4513 /* Onboarding */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C781213DD7DD004B4513 /* Timeline */, D641C781213DD7DD004B4513 /* Timeline */,
D641C784213DD819004B4513 /* Profile */, D641C784213DD819004B4513 /* Profile */,
D641C785213DD83B004B4513 /* Conversation */, D641C785213DD83B004B4513 /* Conversation */,
@ -1227,6 +1234,16 @@
path = "Status Action Account List"; path = "Status Action Account List";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */ = {
isa = PBXGroup;
children = (
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */,
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */,
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */,
);
path = "Fast Account Switcher";
sourceTree = "<group>";
};
D6A5BB2623BAC88E003BF21D /* Preferences */ = { D6A5BB2623BAC88E003BF21D /* Preferences */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1691,6 +1708,7 @@
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */, D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */,
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */, D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */, D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -1830,7 +1848,9 @@
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */, D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */,
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */, 0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */,
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */, D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */,
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */, D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */, D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */, D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,

View File

@ -10,7 +10,7 @@ import UIKit
extension UIFont { extension UIFont {
func addingTraits(_ traits: UIFontDescriptor.SymbolicTraits, size: CGFloat? = nil) -> UIFont? { func withTraits(_ traits: UIFontDescriptor.SymbolicTraits, size: CGFloat? = nil) -> UIFont? {
let descriptor = self.fontDescriptor let descriptor = self.fontDescriptor
guard let newDescriptor = descriptor.withSymbolicTraits([descriptor.symbolicTraits, traits]) else { guard let newDescriptor = descriptor.withSymbolicTraits([descriptor.symbolicTraits, traits]) else {
return nil return nil

View File

@ -87,7 +87,7 @@ class LocalData: ObservableObject {
} }
private let mostRecentAccountKey = "mostRecentAccount" private let mostRecentAccountKey = "mostRecentAccount"
private var mostRecentAccount: String? { private(set) var mostRecentAccountID: String? {
get { get {
return defaults.string(forKey: mostRecentAccountKey) return defaults.string(forKey: mostRecentAccountKey)
} }
@ -131,7 +131,7 @@ class LocalData: ObservableObject {
func getMostRecentAccount() -> UserAccountInfo? { func getMostRecentAccount() -> UserAccountInfo? {
guard onboardingComplete else { return nil } guard onboardingComplete else { return nil }
let mostRecent: UserAccountInfo? let mostRecent: UserAccountInfo?
if let id = mostRecentAccount { if let id = mostRecentAccountID {
mostRecent = accounts.first { $0.id == id } mostRecent = accounts.first { $0.id == id }
} else { } else {
mostRecent = nil mostRecent = nil
@ -140,7 +140,7 @@ class LocalData: ObservableObject {
} }
func setMostRecentAccount(_ account: UserAccountInfo?) { func setMostRecentAccount(_ account: UserAccountInfo?) {
mostRecentAccount = account?.id mostRecentAccountID = account?.id
} }
} }

View File

@ -0,0 +1,244 @@
//
// FastAccountSwitcherViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/4/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
protocol FastAccountSwitcherViewControllerDelegate: class {
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 var accountViews: [FastSwitchingAccountView] = []
private var lastSelectedAccountViewIndex: Int?
private var selectionChangedFeedbackGenerator: UIImpactFeedbackGenerator?
private var touchBeganFeedbackWorkItem: DispatchWorkItem?
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()
view.isHidden = false
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
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) {
accountView.alpha = 1
accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}
UIView.addKeyframe(withRelativeStartTime: relStart + relDuration, relativeDuration: relDuration) {
accountView.transform = .identity
}
}
}
}
}
func hide() {
lastSelectedAccountViewIndex = nil
selectionChangedFeedbackGenerator = nil
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
self.view.alpha = 0
} completion: { (_) in
self.view.alpha = 1
self.view.isHidden = true
}
}
private func createAccountViews() {
accountsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
accountViews = []
for account in LocalData.shared.accounts {
let accountView = FastSwitchingAccountView(account: account)
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) {
let account = LocalData.shared.accounts[newIndex]
if account.id != LocalData.shared.mostRecentAccountID {
if hapticFeedback {
selectionChangedFeedbackGenerator?.impactOccurred()
}
selectionChangedFeedbackGenerator = nil
(view.window!.windowScene!.delegate as! SceneDelegate).activateAccount(account)
}
}
// 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:
let location = recognizer.location(in: view)
if let index = lastSelectedAccountViewIndex {
switchAccount(newIndex: index)
hide()
} else if !(delegate?.fastAccountSwitcher(self, triggerZoneContains: location) ?? 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)
hide()
}
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)
}
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: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let point = gestureRecognizer.location(in: view)
return delegate?.fastAccountSwitcher(self, triggerZoneContains: point) ?? false
}
}

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="FastAccountSwitcherViewController" customModule="Tusker" customModuleProvider="target">
<connections>
<outlet property="accountsStack" destination="lYU-Bb-3Wi" id="Dxs-ta-ORu"/>
<outlet property="blurContentView" destination="1Gd-Da-Vab" id="JqT-uq-1o2"/>
<outlet property="dimmingView" destination="Lul-oI-bZ7" id="JhP-ZX-8fb"/>
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view alpha="0.25" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Lul-oI-bZ7">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5fd-Ni-Owc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="1Gd-Da-Vab">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="bottom" translatesAutoresizingMaskIntoConstraints="NO" id="lYU-Bb-3Wi">
<rect key="frame" x="8" y="8" width="398" height="880"/>
</stackView>
</subviews>
<constraints>
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="top" secondItem="1Gd-Da-Vab" secondAttribute="topMargin" placeholder="YES" id="KQs-d5-U3f"/>
<constraint firstAttribute="trailing" secondItem="lYU-Bb-3Wi" secondAttribute="trailingMargin" constant="8" id="UZh-xR-XVt"/>
<constraint firstAttribute="bottomMargin" secondItem="lYU-Bb-3Wi" secondAttribute="bottom" id="j6f-r5-NNI"/>
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="leading" secondItem="1Gd-Da-Vab" secondAttribute="leading" constant="8" id="sae-ga-MGE"/>
</constraints>
</view>
<blurEffect style="systemThinMaterialDark"/>
</visualEffectView>
</subviews>
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<gestureRecognizers/>
<constraints>
<constraint firstAttribute="trailing" secondItem="Lul-oI-bZ7" secondAttribute="trailing" id="9Fp-IG-O9W"/>
<constraint firstAttribute="trailing" secondItem="5fd-Ni-Owc" secondAttribute="trailing" id="c27-P9-lLK"/>
<constraint firstAttribute="bottom" secondItem="Lul-oI-bZ7" secondAttribute="bottom" id="o6y-tG-MwH"/>
<constraint firstItem="5fd-Ni-Owc" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="phf-PC-bdH"/>
<constraint firstItem="5fd-Ni-Owc" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="rz7-cQ-PIC"/>
<constraint firstAttribute="bottom" secondItem="5fd-Ni-Owc" secondAttribute="bottom" id="sHl-iD-kGi"/>
<constraint firstItem="Lul-oI-bZ7" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="tfE-Xr-YBo"/>
<constraint firstItem="Lul-oI-bZ7" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="ua7-DO-kdp"/>
</constraints>
<variation key="default">
<mask key="subviews">
<exclude reference="Lul-oI-bZ7"/>
</mask>
</variation>
<point key="canvasLocation" x="140.57971014492756" y="144.64285714285714"/>
</view>
</objects>
</document>

View File

@ -0,0 +1,116 @@
//
// FastSwitchingAccountView.swift
// Tusker
//
// Created by Shadowfacts on 11/4/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
class FastSwitchingAccountView: UIView {
let account: LocalData.UserAccountInfo
private static let selectedColor = UIColor { (traits) in
if traits.userInterfaceStyle == .dark {
return UIColor(hue: 211 / 360, saturation: 85 / 100, brightness: 100 / 100, alpha: 1)
} else {
return UIColor(hue: 211 / 360, saturation: 70 / 100, brightness: 100 / 100, alpha: 1)
}
}
private static let currentColor = UIColor { (traits) in
if traits.userInterfaceStyle == .dark {
return UIColor(hue: 211 / 360, saturation: 85 / 100, brightness: 85 / 100, alpha: 1)
} else {
return UIColor(hue: 211 / 360, saturation: 50 / 100, brightness: 100 / 100, alpha: 1)
}
}
var isSelected = false {
didSet {
updateLabelColors()
}
}
var isCurrent = false {
didSet {
updateLabelColors()
}
}
private let usernameLabel = UILabel()
private let instanceLabel = UILabel()
private var avatarRequest: ImageCache.Request?
init(account: LocalData.UserAccountInfo) {
self.account = account
super.init(frame: .zero)
usernameLabel.textColor = .white
usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline), size: 0)
usernameLabel.text = account.username
instanceLabel.textColor = .white
instanceLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .subheadline), size: 0)
instanceLabel.text = account.instanceURL.host!
let stackView = UIStackView(arrangedSubviews: [
usernameLabel,
instanceLabel
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .trailing
addSubview(stackView)
let avatarImageView = UIImageView()
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 40
avatarImageView.image = UIImage(systemName: Preferences.shared.avatarStyle == .circle ? "person.crop.circle" : "person.crop.square")
avatarImageView.contentMode = .scaleAspectFit
addSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
avatarImageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
avatarImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: 40),
avatarImageView.heightAnchor.constraint(equalTo: avatarImageView.widthAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
stackView.trailingAnchor.constraint(equalTo: avatarImageView.leadingAnchor, constant: -8),
stackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
])
let controller = MastodonController.getForAccount(account)
controller.getOwnAccount { [weak self] (result) in
guard let self = self, case let .success(account) = result else { return }
self.avatarRequest = ImageCache.avatars.get(account.avatar) { [weak avatarImageView] (data) in
guard let avatarImageView = avatarImageView, let data = data, let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
avatarImageView.image = image
}
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateLabelColors() {
let color: UIColor
if isSelected {
color = FastSwitchingAccountView.selectedColor
} else if isCurrent {
color = FastSwitchingAccountView.currentColor
} else {
color = .white
}
usernameLabel.textColor = color
instanceLabel.textColor = color
}
}

View File

@ -14,6 +14,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController! private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
var selectedTab: Tab { var selectedTab: Tab {
return Tab(rawValue: selectedIndex)! return Tab(rawValue: selectedIndex)!
@ -53,6 +54,26 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
embedInNavigationController(Tab.explore.createViewController(mastodonController)), embedInNavigationController(Tab.explore.createViewController(mastodonController)),
embedInNavigationController(Tab.myProfile.createViewController(mastodonController)), embedInNavigationController(Tab.myProfile.createViewController(mastodonController)),
] ]
fastAccountSwitcher = FastAccountSwitcherViewController()
fastAccountSwitcher.delegate = self
fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(fastAccountSwitcher.view)
NSLayoutConstraint.activate([
fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
])
tabBar.addGestureRecognizer(fastAccountSwitcher.createSwitcherGesture())
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tabBarTapped))
tapRecognizer.cancelsTouchesInView = false
tabBar.addGestureRecognizer(tapRecognizer)
}
@objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) {
fastAccountSwitcher.hide()
} }
func embedInNavigationController(_ vc: UIViewController) -> UINavigationController { func embedInNavigationController(_ vc: UIViewController) -> UINavigationController {
@ -120,6 +141,20 @@ extension MainTabBarViewController {
} }
} }
extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).lowercased().contains("button") }
// sanity check that there is 1 button per VC
guard tabBarButtons.count == viewControllers!.count,
let myProfileButton = tabBarButtons.last else {
return false
}
let locationInButton = myProfileButton.convert(point, from: fastAccountSwitcher.view)
return myProfileButton.bounds.contains(locationInButton)
}
}
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
func presentCompose() { func presentCompose() {
let vc = ComposeHostingController(mastodonController: mastodonController) let vc = ComposeHostingController(mastodonController: mastodonController)

View File

@ -137,10 +137,10 @@ class ContentTextView: LinkTextView {
attributed.append(NSAttributedString(string: "\n\n")) attributed.append(NSAttributedString(string: "\n\n"))
case "em", "i": case "em", "i":
let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font! let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font!
attributed.addAttribute(.font, value: currentFont.addingTraits(.traitItalic)!, range: attributed.fullRange) attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
case "strong", "b": case "strong", "b":
let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font! let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font!
attributed.addAttribute(.font, value: currentFont.addingTraits(.traitBold)!, range: attributed.fullRange) attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
case "del": case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
case "code": case "code":