forked from shadowfacts/Tusker
Add jump to present button to timelines
This commit is contained in:
parent
1832e64ad7
commit
8bc185ecf9
|
@ -298,6 +298,7 @@
|
|||
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; };
|
||||
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.swift */; };
|
||||
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */; };
|
||||
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
|
||||
|
@ -710,6 +711,7 @@
|
|||
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = "<group>"; };
|
||||
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; };
|
||||
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppListStyle.swift"; sourceTree = "<group>"; };
|
||||
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineJumpButton.swift; sourceTree = "<group>"; };
|
||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
|
||||
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
|
||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1050,6 +1052,7 @@
|
|||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
|
||||
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */,
|
||||
);
|
||||
path = Timeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2090,6 +2093,7 @@
|
|||
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
|
||||
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
|
||||
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
|
||||
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
|
||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
|
||||
|
|
|
@ -50,7 +50,7 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
|
|||
extension FindInstanceViewController: InstanceSelectorTableViewControllerDelegate {
|
||||
func didSelectInstance(url: URL) {
|
||||
let instanceTimelineController = InstanceTimelineViewController(for: url, parentMastodonController: parentMastodonController!)
|
||||
instanceTimelineController.delegate = instanceTimelineDelegate
|
||||
instanceTimelineController.instanceTimelineDelegate = instanceTimelineDelegate
|
||||
instanceTimelineController.browsingEnabled = false
|
||||
show(instanceTimelineController, sender: self)
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ protocol InstanceTimelineViewControllerDelegate: AnyObject {
|
|||
|
||||
class InstanceTimelineViewController: TimelineViewController {
|
||||
|
||||
weak var delegate: InstanceTimelineViewControllerDelegate?
|
||||
weak var instanceTimelineDelegate: InstanceTimelineViewControllerDelegate?
|
||||
|
||||
weak var parentMastodonController: MastodonController?
|
||||
|
||||
|
@ -145,10 +145,10 @@ class InstanceTimelineViewController: TimelineViewController {
|
|||
let existing = try? context.fetch(req).first
|
||||
if let existing = existing {
|
||||
context.delete(existing)
|
||||
delegate?.didUnsaveInstance(url: instanceURL)
|
||||
instanceTimelineDelegate?.didUnsaveInstance(url: instanceURL)
|
||||
} else {
|
||||
_ = SavedInstance(url: instanceURL, account: parentMastodonController!.accountInfo!, context: context)
|
||||
delegate?.didSaveInstance(url: instanceURL)
|
||||
instanceTimelineDelegate?.didSaveInstance(url: instanceURL)
|
||||
}
|
||||
mastodonController.persistentContainer.save(context: context)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
//
|
||||
// TimelineJumpButton.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 2/6/23.
|
||||
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TimelineJumpButton: UIView {
|
||||
|
||||
var action: ((Mode) async -> Void)?
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: 44)
|
||||
}
|
||||
|
||||
private let button: UIButton = {
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.image = UIImage(systemName: "arrow.up")
|
||||
config.contentInsets = .zero
|
||||
return UIButton(configuration: config)
|
||||
}()
|
||||
|
||||
private(set) var mode = Mode.jump
|
||||
var offscreen = false {
|
||||
didSet {
|
||||
updateOffscreenTransform()
|
||||
}
|
||||
}
|
||||
private(set) var isSyncing = false
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
layer.masksToBounds = true
|
||||
|
||||
button.addAction(UIAction(handler: { [unowned self] _ in
|
||||
Task {
|
||||
switch self.mode {
|
||||
case .jump:
|
||||
await self.jumpAction()
|
||||
case .sync:
|
||||
await self.syncAction()
|
||||
}
|
||||
}
|
||||
}), for: .touchUpInside)
|
||||
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(button)
|
||||
NSLayoutConstraint.activate([
|
||||
button.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
button.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
button.topAnchor.constraint(equalTo: topAnchor),
|
||||
button.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
let jumpToPresentAction = UIAction(title: "Jump to Present", image: UIImage(systemName: "arrow.up")) { [unowned self] _ in
|
||||
Task {
|
||||
self.setMode(.jump, animated: false)
|
||||
await self.jumpAction()
|
||||
}
|
||||
}
|
||||
jumpToPresentAction.accessibilityAttributedLabel = TimelinesPageViewController.jumpToPresentTitle
|
||||
|
||||
button.menu = UIMenu(children: [
|
||||
jumpToPresentAction,
|
||||
UIAction(title: "Sync Position", image: UIImage(systemName: "arrow.triangle.2.circlepath"), handler: { [unowned self] _ in
|
||||
Task {
|
||||
self.setMode(.sync, animated: false)
|
||||
await self.syncAction()
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
updateOffscreenTransform()
|
||||
}
|
||||
|
||||
private func updateOffscreenTransform() {
|
||||
if offscreen {
|
||||
button.transform = CGAffineTransform(translationX: 0, y: button.bounds.height)
|
||||
} else {
|
||||
button.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
func setMode(_ mode: Mode, animated: Bool) {
|
||||
guard self.mode != mode else {
|
||||
return
|
||||
}
|
||||
self.mode = mode
|
||||
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.contentInsets = .zero
|
||||
switch mode {
|
||||
case .jump:
|
||||
config.image = UIImage(systemName: "arrow.up")
|
||||
case .sync:
|
||||
config.image = UIImage(systemName: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
|
||||
if animated,
|
||||
let snapshot = button.snapshotView(afterScreenUpdates: false) {
|
||||
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(snapshot)
|
||||
NSLayoutConstraint.activate([
|
||||
snapshot.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
snapshot.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
button.configuration = config
|
||||
button.layer.opacity = 0
|
||||
UIView.animate(withDuration: 0.5, delay: 0) {
|
||||
self.button.layer.opacity = 1
|
||||
snapshot.layer.opacity = 0
|
||||
} completion: { _ in
|
||||
snapshot.removeFromSuperview()
|
||||
}
|
||||
} else {
|
||||
button.configuration = config
|
||||
}
|
||||
}
|
||||
|
||||
private func jumpAction() async {
|
||||
button.isUserInteractionEnabled = false
|
||||
var config = button.configuration!
|
||||
config.showsActivityIndicator = true
|
||||
button.configuration = config
|
||||
|
||||
await action?(.jump)
|
||||
|
||||
config.showsActivityIndicator = false
|
||||
button.configuration = config
|
||||
button.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
private func syncAction() async {
|
||||
isSyncing = true
|
||||
button.isUserInteractionEnabled = false
|
||||
UIView.animateKeyframes(withDuration: 1, delay: 0, options: .repeat) {
|
||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||
self.button.imageView!.transform = CGAffineTransform(rotationAngle: 0.5 * .pi)
|
||||
}
|
||||
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||
// the translation is because the symbol isn't perfectly centered
|
||||
self.button.imageView!.transform = CGAffineTransform(translationX: -0.5, y: 0).rotated(by: .pi)
|
||||
}
|
||||
} completion: { _ in
|
||||
}
|
||||
|
||||
await action?(.sync)
|
||||
|
||||
button.imageView!.layer.removeAllAnimations()
|
||||
button.imageView!.transform = .identity
|
||||
button.isUserInteractionEnabled = true
|
||||
isSyncing = false
|
||||
setMode(.jump, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelineJumpButton {
|
||||
enum Mode {
|
||||
case jump
|
||||
case sync
|
||||
}
|
||||
}
|
|
@ -11,7 +11,23 @@ import Pachyderm
|
|||
import Combine
|
||||
import Sentry
|
||||
|
||||
protocol TimelineViewControllerDelegate: AnyObject {
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?)
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?)
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willShowSyncToastWith animator: UIViewPropertyAnimator?)
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?)
|
||||
}
|
||||
|
||||
extension TimelineViewControllerDelegate {
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?) {}
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?) {}
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willShowSyncToastWith animator: UIViewPropertyAnimator?) {}
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {}
|
||||
}
|
||||
|
||||
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
||||
weak var delegate: TimelineViewControllerDelegate?
|
||||
|
||||
let timeline: Timeline
|
||||
weak var mastodonController: MastodonController!
|
||||
let filterer: Filterer
|
||||
|
@ -145,7 +161,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.sink { [unowned self] _ in
|
||||
_ = syncPositionIfNecessary(alwaysPrompt: true)
|
||||
Task {
|
||||
_ = await syncPositionIfNecessary(alwaysPrompt: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||
|
@ -227,7 +245,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
if case .notLoadedInitial = controller.state {
|
||||
Task {
|
||||
if await restoreState() {
|
||||
await checkPresent(jumpImmediately: false)
|
||||
await checkPresent(jumpImmediately: false, animateImmediateJump: false)
|
||||
} else {
|
||||
await controller.loadInitial()
|
||||
}
|
||||
|
@ -359,28 +377,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
|
||||
@MainActor
|
||||
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
|
||||
{
|
||||
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
|
||||
crumb.message = "Original statusIDs before filtering"
|
||||
crumb.data = [
|
||||
"statusIDs": position.statusIDs,
|
||||
]
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}()
|
||||
let originalPositionStatusIDs = position.statusIDs
|
||||
|
||||
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
|
||||
guard !unloaded.isEmpty else {
|
||||
return true
|
||||
}
|
||||
{
|
||||
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
|
||||
crumb.message = "Unloaded ids"
|
||||
crumb.data = [
|
||||
"unloaded": unloaded
|
||||
]
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}()
|
||||
let results = await withTaskGroup(of: (String, Result<Status, Swift.Error>).self) { group -> [(String, Result<Status, Swift.Error>)] in
|
||||
for id in unloaded {
|
||||
group.addTask {
|
||||
|
@ -401,9 +403,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
switch result {
|
||||
case .success(let status):
|
||||
statuses.append(status)
|
||||
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
|
||||
crumb.message = "Loaded status \(id)"
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
case .failure(let error):
|
||||
let crumb = Breadcrumb(level: .error, category: "TimelineViewController")
|
||||
crumb.message = "Error loading status"
|
||||
|
@ -416,15 +415,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses, in: mastodonController.persistentContainer.viewContext)
|
||||
|
||||
_ = {
|
||||
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
|
||||
crumb.message = "Position statusIDs before filtering"
|
||||
crumb.data = [
|
||||
"statusIDs": position.statusIDs,
|
||||
]
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}()
|
||||
|
||||
// if an icloud sync completed in between starting to load the statuses and finishing, try to load again
|
||||
if position.statusIDs != originalPositionStatusIDs {
|
||||
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
|
||||
|
@ -446,15 +436,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
!unloaded.contains(id) || statuses.contains(where: { $0.id == id })
|
||||
}
|
||||
|
||||
{
|
||||
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
|
||||
crumb.message = "Filtered position statusIDs"
|
||||
crumb.data = [
|
||||
"statusIDs": position.statusIDs,
|
||||
]
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}()
|
||||
|
||||
return !position.statusIDs.isEmpty
|
||||
}
|
||||
|
||||
|
@ -565,7 +546,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
saveState()
|
||||
}
|
||||
|
||||
private func syncPositionIfNecessary(alwaysPrompt: Bool) -> Bool {
|
||||
func syncPositionIfNecessary(alwaysPrompt: Bool) async -> Bool {
|
||||
guard persistsState,
|
||||
let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||
return false
|
||||
|
@ -593,9 +574,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
stateRestorationLogger.info("Potential restore with centerStatusID: \(timelinePosition.centerStatusID ?? "<none>")")
|
||||
if !alwaysPrompt {
|
||||
Task {
|
||||
_ = await restoreState()
|
||||
}
|
||||
_ = await restoreState()
|
||||
} else {
|
||||
var config = ToastConfiguration(title: "Sync Position")
|
||||
config.edge = .top
|
||||
|
@ -617,6 +596,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
toast.dismissToast(animated: true)
|
||||
}
|
||||
}
|
||||
config.onAppear = { [unowned self] animator in
|
||||
self.delegate?.timelineViewController(self, willShowSyncToastWith: animator)
|
||||
}
|
||||
config.onDismiss = { [unowned self] animator in
|
||||
self.delegate?.timelineViewController(self, willDismissSyncToastWith: animator)
|
||||
}
|
||||
showToast(configuration: config, animated: true)
|
||||
UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated")
|
||||
}
|
||||
|
@ -654,30 +639,31 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
return
|
||||
}
|
||||
self.disappearedAt = nil
|
||||
if syncPositionIfNecessary(alwaysPrompt: false) {
|
||||
// no-op
|
||||
} else {
|
||||
Task {
|
||||
await checkPresent(jumpImmediately: false)
|
||||
Task {
|
||||
if await syncPositionIfNecessary(alwaysPrompt: false) {
|
||||
// no-op
|
||||
} else {
|
||||
await checkPresent(jumpImmediately: false, animateImmediateJump: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkPresent(jumpImmediately: Bool) async {
|
||||
if case .idle = controller.state,
|
||||
let presentItems = try? await loadInitial(),
|
||||
!presentItems.isEmpty {
|
||||
if jumpImmediately {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
|
||||
UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0)))
|
||||
}
|
||||
} else {
|
||||
insertPresentItemsAndShowJumpToast(presentItems)
|
||||
func checkPresent(jumpImmediately: Bool, animateImmediateJump: Bool) async {
|
||||
guard case .idle = controller.state,
|
||||
let presentItems = try? await loadInitial(),
|
||||
!presentItems.isEmpty else {
|
||||
return
|
||||
}
|
||||
if jumpImmediately {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
||||
dataSource.apply(snapshot, animatingDifferences: animateImmediateJump) {
|
||||
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: animateImmediateJump)
|
||||
UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0)))
|
||||
}
|
||||
} else {
|
||||
insertPresentItemsAndShowJumpToast(presentItems)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -780,6 +766,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
config.onAppear = { [unowned self] animator in
|
||||
self.delegate?.timelineViewController(self, willShowJumpToPresentToastWith: animator)
|
||||
}
|
||||
config.onDismiss = { [unowned self] animator in
|
||||
self.delegate?.timelineViewController(self, willDismissJumpToPresentToastWith: animator)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,12 +13,22 @@ import Combine
|
|||
|
||||
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
||||
|
||||
static let jumpToPresentTitle: NSAttributedString = {
|
||||
let s = NSMutableAttributedString("Jump to Present")
|
||||
// otherwise it pronounces it as 'pɹizˈənt'
|
||||
// its IPA is also bad, this should be an alveolar approximant not a trill
|
||||
s.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
|
||||
return s
|
||||
}()
|
||||
|
||||
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
|
||||
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
|
||||
private let localTitle = NSLocalizedString("Local", comment: "local timeline tab title")
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
private let jumpButton = TimelineJumpButton()
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
|
@ -46,30 +56,17 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
|||
customizeItem.accessibilityLabel = "Customize Timelines"
|
||||
navigationItem.rightBarButtonItem = customizeItem
|
||||
|
||||
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
|
||||
// otherwise it pronounces it as 'pɹizˈənt'
|
||||
// its IPA is also bad, this should be an alveolar approximant not a trill
|
||||
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
|
||||
segmentedControl.accessibilityCustomActions = [
|
||||
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in
|
||||
guard let vc = currentViewController as? TimelineViewController else {
|
||||
return false
|
||||
}
|
||||
Task {
|
||||
await vc.checkPresent(jumpImmediately: true)
|
||||
}
|
||||
return true
|
||||
}),
|
||||
UIAccessibilityCustomAction(name: "Jump to Sync Position", actionHandler: { [unowned self] _ in
|
||||
guard let vc = currentViewController as? TimelineViewController else {
|
||||
return false
|
||||
}
|
||||
Task {
|
||||
_ = await vc.restoreState()
|
||||
}
|
||||
return true
|
||||
}),
|
||||
]
|
||||
jumpButton.action = { [unowned self] mode in
|
||||
switch mode {
|
||||
case .jump:
|
||||
await (self.currentViewController as! TimelineViewController).checkPresent(jumpImmediately: true, animateImmediateJump: true)
|
||||
case .sync:
|
||||
_ = await (self.currentViewController as! TimelineViewController).syncPositionIfNecessary(alwaysPrompt: false)
|
||||
}
|
||||
}
|
||||
let jumpItem = UIBarButtonItem(customView: jumpButton)
|
||||
jumpItem.accessibilityAttributedLabel = Self.jumpToPresentTitle
|
||||
navigationItem.leftBarButtonItem = jumpItem
|
||||
|
||||
mastodonController.accountPreferences.publisher(for: \.pinnedTimelinesData)
|
||||
.map { _ in () }
|
||||
|
@ -87,6 +84,23 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func configureViewController(_ viewController: UIViewController) {
|
||||
let vc = viewController as! TimelineViewController
|
||||
vc.delegate = self
|
||||
}
|
||||
|
||||
override func selectPage(_ page: Page, animated: Bool) {
|
||||
super.selectPage(page, animated: animated)
|
||||
if jumpButton.offscreen {
|
||||
jumpButton.setMode(.jump, animated: false)
|
||||
UIView.animate(withDuration: 0.2, delay: 0) {
|
||||
self.jumpButton.offscreen = false
|
||||
}
|
||||
} else {
|
||||
jumpButton.setMode(.jump, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func selectTimeline(_ timeline: PinnedTimeline, animated: Bool) {
|
||||
self.selectPage(Page(mastodonController: mastodonController, timeline: timeline), animated: animated)
|
||||
}
|
||||
|
@ -136,3 +150,75 @@ extension TimelinesPageViewController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelinesPageViewController: TimelineViewControllerDelegate {
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willShowJumpToPresentToastWith animator: UIViewPropertyAnimator?) {
|
||||
guard timelineViewController === currentViewController else {
|
||||
return
|
||||
}
|
||||
if let animator {
|
||||
animator.addAnimations {
|
||||
self.jumpButton.offscreen = true
|
||||
}
|
||||
} else {
|
||||
self.jumpButton.offscreen = true
|
||||
}
|
||||
}
|
||||
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissJumpToPresentToastWith animator: UIViewPropertyAnimator?) {
|
||||
guard timelineViewController === currentViewController else {
|
||||
return
|
||||
}
|
||||
jumpButton.setMode(.jump, animated: false)
|
||||
if let animator {
|
||||
animator.addAnimations {
|
||||
self.jumpButton.offscreen = false
|
||||
}
|
||||
} else {
|
||||
self.jumpButton.offscreen = false
|
||||
}
|
||||
}
|
||||
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willShowSyncToastWith animator: UIViewPropertyAnimator?) {
|
||||
guard timelineViewController === currentViewController else {
|
||||
return
|
||||
}
|
||||
if let animator {
|
||||
animator.addAnimations {
|
||||
self.jumpButton.offscreen = true
|
||||
}
|
||||
} else {
|
||||
self.jumpButton.offscreen = true
|
||||
}
|
||||
}
|
||||
|
||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {
|
||||
guard timelineViewController === currentViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
jumpButton.setMode(.sync, animated: false)
|
||||
func resetJumpButtonMode() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in
|
||||
guard let self,
|
||||
timelineViewController === self.currentViewController,
|
||||
!self.jumpButton.isSyncing else { return }
|
||||
self.jumpButton.setMode(.jump, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let animator {
|
||||
animator.addAnimations {
|
||||
self.jumpButton.offscreen = false
|
||||
}
|
||||
animator.addCompletion { position in
|
||||
if position == .end {
|
||||
resetJumpButtonMode()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.jumpButton.offscreen = false
|
||||
resetJumpButtonMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,6 +94,9 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
|
|||
}
|
||||
}
|
||||
|
||||
func configureViewController(_ viewController: UIViewController) {
|
||||
}
|
||||
|
||||
func selectPage(_ page: Page, animated: Bool) {
|
||||
guard pages.contains(page) else {
|
||||
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
|
||||
|
@ -116,6 +119,7 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
|
|||
newController = existing
|
||||
} else {
|
||||
newController = pageProvider(page)
|
||||
configureViewController(newController)
|
||||
pageControllers[page] = newController
|
||||
}
|
||||
|
||||
|
@ -130,7 +134,16 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
|
|||
animated != .none else {
|
||||
currentViewController?.removeViewAndController()
|
||||
newViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
embedChild(newViewController)
|
||||
// don't use embedChild here because it triggers an appearance transition, even though this vc hasn't appeared yet
|
||||
addChild(newViewController)
|
||||
view.addSubview(newViewController.view)
|
||||
NSLayoutConstraint.activate([
|
||||
newViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
newViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
newViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
newViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
newViewController.didMove(toParent: self)
|
||||
self.currentViewController = newViewController
|
||||
return
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ struct ToastConfiguration {
|
|||
var edge: Edge = .automatic
|
||||
var dismissOnScroll = true
|
||||
var dismissAutomaticallyAfter: TimeInterval? = nil
|
||||
var onAppear: ((UIViewPropertyAnimator?) -> Void)?
|
||||
var onDismiss: ((UIViewPropertyAnimator?) -> Void)?
|
||||
|
||||
init(title: String) {
|
||||
self.title = title
|
||||
|
|
|
@ -127,22 +127,30 @@ class ToastView: UIView {
|
|||
func dismissToast(animated: Bool) {
|
||||
guard animated else {
|
||||
removeFromSuperview()
|
||||
configuration.onDismiss?(nil)
|
||||
return
|
||||
}
|
||||
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.25, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
|
||||
} completion: { (_) in
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
self.removeFromSuperview()
|
||||
}
|
||||
configuration.onDismiss?(animator)
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
func animateAppearance() {
|
||||
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
|
||||
let duration = 0.5
|
||||
let velocity = 0.5
|
||||
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
|
||||
let velocity = CGVector(dx: 0, dy: 0.5)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: UISpringTimingParameters(dampingRatio: 0.65, initialVelocity: velocity))
|
||||
animator.addAnimations {
|
||||
self.transform = .identity
|
||||
}
|
||||
configuration.onAppear?(animator)
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
func setupDismissOnScroll(connectedTo scrollView: UIScrollView) {
|
||||
|
@ -184,11 +192,14 @@ class ToastView: UIView {
|
|||
shrinkAnimator = nil
|
||||
}
|
||||
|
||||
private var panGestureDismissAnimator: UIViewPropertyAnimator?
|
||||
|
||||
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||
let translation = recognizer.translation(in: self).y
|
||||
|
||||
let isDraggingAwayFromDismissalEdge = (configuration.edge == .top && translation > 0) || (configuration.edge == .bottom && translation < 0)
|
||||
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
recognizedGesture = true
|
||||
|
@ -196,19 +207,25 @@ class ToastView: UIView {
|
|||
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
||||
self.transform = .identity
|
||||
}
|
||||
break
|
||||
panGestureDismissAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut)
|
||||
configuration.onDismiss?(panGestureDismissAnimator!)
|
||||
|
||||
case .changed:
|
||||
var distance: CGFloat
|
||||
var alongsideAnimationProgress: CGFloat
|
||||
if isDraggingAwayFromDismissalEdge {
|
||||
distance = sqrt(abs(translation))
|
||||
if configuration.edge == .bottom {
|
||||
distance *= -1
|
||||
}
|
||||
alongsideAnimationProgress = 0
|
||||
} else {
|
||||
distance = translation
|
||||
alongsideAnimationProgress = abs(translation) / (configuration.edgeSpacing + bounds.height)
|
||||
}
|
||||
|
||||
transform = CGAffineTransform(translationX: 0, y: distance)
|
||||
panGestureDismissAnimator!.fractionComplete = alongsideAnimationProgress
|
||||
|
||||
case .ended, .cancelled:
|
||||
let velocity = recognizer.velocity(in: self).y
|
||||
|
@ -219,27 +236,31 @@ class ToastView: UIView {
|
|||
|
||||
let minDismissalVelocity: CGFloat = 250
|
||||
let dismissDueToVelocity = configuration.edge == .bottom ? velocity > minDismissalDistance : velocity < -minDismissalVelocity
|
||||
|
||||
if dismissDueToDistance || dismissDueToVelocity {
|
||||
|
||||
if abs(translation) < abs(offscreenTranslation) {
|
||||
let distanceLeft = offscreenTranslation - translation
|
||||
let duration = 1 / TimeInterval(max(velocity, minDismissalVelocity) / distanceLeft)
|
||||
|
||||
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction) {
|
||||
panGestureDismissAnimator!.addAnimations {
|
||||
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
|
||||
} completion: { (_) in
|
||||
}
|
||||
panGestureDismissAnimator!.addCompletion { _ in
|
||||
self.removeFromSuperview()
|
||||
}
|
||||
} else {
|
||||
self.removeFromSuperview()
|
||||
}
|
||||
|
||||
panGestureDismissAnimator!.startAnimation()
|
||||
|
||||
} else {
|
||||
let duration = 0.5
|
||||
let velocity = distance * duration
|
||||
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: .allowUserInteraction) {
|
||||
self.transform = .identity
|
||||
}
|
||||
|
||||
panGestureDismissAnimator!.isReversed = true
|
||||
panGestureDismissAnimator!.startAnimation()
|
||||
}
|
||||
|
||||
default:
|
||||
|
|
|
@ -77,6 +77,8 @@ extension ToastableViewController {
|
|||
|
||||
if animated {
|
||||
toast.animateAppearance()
|
||||
} else {
|
||||
config.onAppear?(nil)
|
||||
}
|
||||
|
||||
if config.dismissOnScroll,
|
||||
|
|
Loading…
Reference in New Issue