Add jump to present button to timelines

This commit is contained in:
Shadowfacts 2023-02-07 23:52:23 -05:00
parent 1832e64ad7
commit 8bc185ecf9
10 changed files with 396 additions and 101 deletions

View File

@ -298,6 +298,7 @@
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; }; D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; };
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.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 */; }; 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 */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.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 */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -1050,6 +1052,7 @@
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */, D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */, D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */,
); );
path = Timeline; path = Timeline;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2090,6 +2093,7 @@
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */, D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */, D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */, D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */, D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,

View File

@ -50,7 +50,7 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
extension FindInstanceViewController: InstanceSelectorTableViewControllerDelegate { extension FindInstanceViewController: InstanceSelectorTableViewControllerDelegate {
func didSelectInstance(url: URL) { func didSelectInstance(url: URL) {
let instanceTimelineController = InstanceTimelineViewController(for: url, parentMastodonController: parentMastodonController!) let instanceTimelineController = InstanceTimelineViewController(for: url, parentMastodonController: parentMastodonController!)
instanceTimelineController.delegate = instanceTimelineDelegate instanceTimelineController.instanceTimelineDelegate = instanceTimelineDelegate
instanceTimelineController.browsingEnabled = false instanceTimelineController.browsingEnabled = false
show(instanceTimelineController, sender: self) show(instanceTimelineController, sender: self)
} }

View File

@ -16,7 +16,7 @@ protocol InstanceTimelineViewControllerDelegate: AnyObject {
class InstanceTimelineViewController: TimelineViewController { class InstanceTimelineViewController: TimelineViewController {
weak var delegate: InstanceTimelineViewControllerDelegate? weak var instanceTimelineDelegate: InstanceTimelineViewControllerDelegate?
weak var parentMastodonController: MastodonController? weak var parentMastodonController: MastodonController?
@ -145,10 +145,10 @@ class InstanceTimelineViewController: TimelineViewController {
let existing = try? context.fetch(req).first let existing = try? context.fetch(req).first
if let existing = existing { if let existing = existing {
context.delete(existing) context.delete(existing)
delegate?.didUnsaveInstance(url: instanceURL) instanceTimelineDelegate?.didUnsaveInstance(url: instanceURL)
} else { } else {
_ = SavedInstance(url: instanceURL, account: parentMastodonController!.accountInfo!, context: context) _ = SavedInstance(url: instanceURL, account: parentMastodonController!.accountInfo!, context: context)
delegate?.didSaveInstance(url: instanceURL) instanceTimelineDelegate?.didSaveInstance(url: instanceURL)
} }
mastodonController.persistentContainer.save(context: context) mastodonController.persistentContainer.save(context: context)
} }

View File

@ -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
}
}

View File

@ -11,7 +11,23 @@ import Pachyderm
import Combine import Combine
import Sentry 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 { class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
weak var delegate: TimelineViewControllerDelegate?
let timeline: Timeline let timeline: Timeline
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let filterer: Filterer let filterer: Filterer
@ -145,7 +161,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [unowned self] _ in .sink { [unowned self] _ in
_ = syncPositionIfNecessary(alwaysPrompt: true) Task {
_ = await syncPositionIfNecessary(alwaysPrompt: true)
}
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
@ -227,7 +245,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
Task { Task {
if await restoreState() { if await restoreState() {
await checkPresent(jumpImmediately: false) await checkPresent(jumpImmediately: false, animateImmediateJump: false)
} else { } else {
await controller.loadInitial() await controller.loadInitial()
} }
@ -359,28 +377,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@MainActor @MainActor
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { 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 originalPositionStatusIDs = position.statusIDs
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil }) let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
guard !unloaded.isEmpty else { guard !unloaded.isEmpty else {
return true 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 let results = await withTaskGroup(of: (String, Result<Status, Swift.Error>).self) { group -> [(String, Result<Status, Swift.Error>)] in
for id in unloaded { for id in unloaded {
group.addTask { group.addTask {
@ -401,9 +403,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
switch result { switch result {
case .success(let status): case .success(let status):
statuses.append(status) statuses.append(status)
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
crumb.message = "Loaded status \(id)"
SentrySDK.addBreadcrumb(crumb)
case .failure(let error): case .failure(let error):
let crumb = Breadcrumb(level: .error, category: "TimelineViewController") let crumb = Breadcrumb(level: .error, category: "TimelineViewController")
crumb.message = "Error loading status" crumb.message = "Error loading status"
@ -416,15 +415,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
await mastodonController.persistentContainer.addAll(statuses: statuses, in: mastodonController.persistentContainer.viewContext) 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 an icloud sync completed in between starting to load the statuses and finishing, try to load again
if position.statusIDs != originalPositionStatusIDs { if position.statusIDs != originalPositionStatusIDs {
let crumb = Breadcrumb(level: .info, category: "TimelineViewController") let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
@ -446,15 +436,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
!unloaded.contains(id) || statuses.contains(where: { $0.id == id }) !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 return !position.statusIDs.isEmpty
} }
@ -565,7 +546,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
saveState() saveState()
} }
private func syncPositionIfNecessary(alwaysPrompt: Bool) -> Bool { func syncPositionIfNecessary(alwaysPrompt: Bool) async -> Bool {
guard persistsState, guard persistsState,
let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return false return false
@ -593,9 +574,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
stateRestorationLogger.info("Potential restore with centerStatusID: \(timelinePosition.centerStatusID ?? "<none>")") stateRestorationLogger.info("Potential restore with centerStatusID: \(timelinePosition.centerStatusID ?? "<none>")")
if !alwaysPrompt { if !alwaysPrompt {
Task { _ = await restoreState()
_ = await restoreState()
}
} else { } else {
var config = ToastConfiguration(title: "Sync Position") var config = ToastConfiguration(title: "Sync Position")
config.edge = .top config.edge = .top
@ -617,6 +596,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
toast.dismissToast(animated: true) 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) showToast(configuration: config, animated: true)
UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated") UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated")
} }
@ -654,30 +639,31 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return return
} }
self.disappearedAt = nil self.disappearedAt = nil
if syncPositionIfNecessary(alwaysPrompt: false) { Task {
// no-op if await syncPositionIfNecessary(alwaysPrompt: false) {
} else { // no-op
Task { } else {
await checkPresent(jumpImmediately: false) await checkPresent(jumpImmediately: false, animateImmediateJump: false)
} }
} }
} }
func checkPresent(jumpImmediately: Bool) async { func checkPresent(jumpImmediately: Bool, animateImmediateJump: Bool) async {
if case .idle = controller.state, guard case .idle = controller.state,
let presentItems = try? await loadInitial(), let presentItems = try? await loadInitial(),
!presentItems.isEmpty { !presentItems.isEmpty else {
if jumpImmediately { return
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() }
snapshot.appendSections([.statuses]) if jumpImmediately {
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses) var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
dataSource.apply(snapshot, animatingDifferences: false) { snapshot.appendSections([.statuses])
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false) snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0))) dataSource.apply(snapshot, animatingDifferences: animateImmediateJump) {
} self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: animateImmediateJump)
} else { UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0)))
insertPresentItemsAndShowJumpToast(presentItems)
} }
} else {
insertPresentItemsAndShowJumpToast(presentItems)
} }
} }
@ -780,6 +766,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.showToast(configuration: config, animated: true) 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) self.showToast(configuration: config, animated: true)
} }
} }

View File

@ -13,12 +13,22 @@ import Combine
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> { 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 homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title") private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
private let localTitle = NSLocalizedString("Local", comment: "local timeline tab title") private let localTitle = NSLocalizedString("Local", comment: "local timeline tab title")
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private let jumpButton = TimelineJumpButton()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
@ -46,30 +56,17 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
customizeItem.accessibilityLabel = "Customize Timelines" customizeItem.accessibilityLabel = "Customize Timelines"
navigationItem.rightBarButtonItem = customizeItem navigationItem.rightBarButtonItem = customizeItem
let jumpToPresentName = NSMutableAttributedString("Jump to Present") jumpButton.action = { [unowned self] mode in
// otherwise it pronounces it as 'pɹizˈənt' switch mode {
// its IPA is also bad, this should be an alveolar approximant not a trill case .jump:
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count)) await (self.currentViewController as! TimelineViewController).checkPresent(jumpImmediately: true, animateImmediateJump: true)
segmentedControl.accessibilityCustomActions = [ case .sync:
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in _ = await (self.currentViewController as! TimelineViewController).syncPositionIfNecessary(alwaysPrompt: false)
guard let vc = currentViewController as? TimelineViewController else { }
return false }
} let jumpItem = UIBarButtonItem(customView: jumpButton)
Task { jumpItem.accessibilityAttributedLabel = Self.jumpToPresentTitle
await vc.checkPresent(jumpImmediately: true) navigationItem.leftBarButtonItem = jumpItem
}
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
}),
]
mastodonController.accountPreferences.publisher(for: \.pinnedTimelinesData) mastodonController.accountPreferences.publisher(for: \.pinnedTimelinesData)
.map { _ in () } .map { _ in () }
@ -87,6 +84,23 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
fatalError("init(coder:) has not been implemented") 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) { func selectTimeline(_ timeline: PinnedTimeline, animated: Bool) {
self.selectPage(Page(mastodonController: mastodonController, timeline: timeline), animated: animated) 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()
}
}
}

View File

@ -94,6 +94,9 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
} }
} }
func configureViewController(_ viewController: UIViewController) {
}
func selectPage(_ page: Page, animated: Bool) { func selectPage(_ page: Page, animated: Bool) {
guard pages.contains(page) else { guard pages.contains(page) else {
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages") fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
@ -116,6 +119,7 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
newController = existing newController = existing
} else { } else {
newController = pageProvider(page) newController = pageProvider(page)
configureViewController(newController)
pageControllers[page] = newController pageControllers[page] = newController
} }
@ -130,7 +134,16 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
animated != .none else { animated != .none else {
currentViewController?.removeViewAndController() currentViewController?.removeViewAndController()
newViewController.view.translatesAutoresizingMaskIntoConstraints = false 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 self.currentViewController = newViewController
return return
} }

View File

@ -22,6 +22,8 @@ struct ToastConfiguration {
var edge: Edge = .automatic var edge: Edge = .automatic
var dismissOnScroll = true var dismissOnScroll = true
var dismissAutomaticallyAfter: TimeInterval? = nil var dismissAutomaticallyAfter: TimeInterval? = nil
var onAppear: ((UIViewPropertyAnimator?) -> Void)?
var onDismiss: ((UIViewPropertyAnimator?) -> Void)?
init(title: String) { init(title: String) {
self.title = title self.title = title

View File

@ -127,22 +127,30 @@ class ToastView: UIView {
func dismissToast(animated: Bool) { func dismissToast(animated: Bool) {
guard animated else { guard animated else {
removeFromSuperview() removeFromSuperview()
configuration.onDismiss?(nil)
return 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) self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
} completion: { (_) in }
animator.addCompletion { _ in
self.removeFromSuperview() self.removeFromSuperview()
} }
configuration.onDismiss?(animator)
animator.startAnimation()
} }
func animateAppearance() { func animateAppearance() {
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation) self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
let duration = 0.5 let duration = 0.5
let velocity = 0.5 let velocity = CGVector(dx: 0, dy: 0.5)
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) { let animator = UIViewPropertyAnimator(duration: duration, timingParameters: UISpringTimingParameters(dampingRatio: 0.65, initialVelocity: velocity))
animator.addAnimations {
self.transform = .identity self.transform = .identity
} }
configuration.onAppear?(animator)
animator.startAnimation()
} }
func setupDismissOnScroll(connectedTo scrollView: UIScrollView) { func setupDismissOnScroll(connectedTo scrollView: UIScrollView) {
@ -184,11 +192,14 @@ class ToastView: UIView {
shrinkAnimator = nil shrinkAnimator = nil
} }
private var panGestureDismissAnimator: UIViewPropertyAnimator?
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) { @objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self).y let translation = recognizer.translation(in: self).y
let isDraggingAwayFromDismissalEdge = (configuration.edge == .top && translation > 0) || (configuration.edge == .bottom && translation < 0) let isDraggingAwayFromDismissalEdge = (configuration.edge == .top && translation > 0) || (configuration.edge == .bottom && translation < 0)
switch recognizer.state { switch recognizer.state {
case .began: case .began:
recognizedGesture = true recognizedGesture = true
@ -196,19 +207,25 @@ class ToastView: UIView {
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.transform = .identity self.transform = .identity
} }
break panGestureDismissAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut)
configuration.onDismiss?(panGestureDismissAnimator!)
case .changed: case .changed:
var distance: CGFloat var distance: CGFloat
var alongsideAnimationProgress: CGFloat
if isDraggingAwayFromDismissalEdge { if isDraggingAwayFromDismissalEdge {
distance = sqrt(abs(translation)) distance = sqrt(abs(translation))
if configuration.edge == .bottom { if configuration.edge == .bottom {
distance *= -1 distance *= -1
} }
alongsideAnimationProgress = 0
} else { } else {
distance = translation distance = translation
alongsideAnimationProgress = abs(translation) / (configuration.edgeSpacing + bounds.height)
} }
transform = CGAffineTransform(translationX: 0, y: distance) transform = CGAffineTransform(translationX: 0, y: distance)
panGestureDismissAnimator!.fractionComplete = alongsideAnimationProgress
case .ended, .cancelled: case .ended, .cancelled:
let velocity = recognizer.velocity(in: self).y let velocity = recognizer.velocity(in: self).y
@ -219,27 +236,31 @@ class ToastView: UIView {
let minDismissalVelocity: CGFloat = 250 let minDismissalVelocity: CGFloat = 250
let dismissDueToVelocity = configuration.edge == .bottom ? velocity > minDismissalDistance : velocity < -minDismissalVelocity let dismissDueToVelocity = configuration.edge == .bottom ? velocity > minDismissalDistance : velocity < -minDismissalVelocity
if dismissDueToDistance || dismissDueToVelocity { if dismissDueToDistance || dismissDueToVelocity {
if abs(translation) < abs(offscreenTranslation) { if abs(translation) < abs(offscreenTranslation) {
let distanceLeft = offscreenTranslation - translation panGestureDismissAnimator!.addAnimations {
let duration = 1 / TimeInterval(max(velocity, minDismissalVelocity) / distanceLeft)
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction) {
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation) self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
} completion: { (_) in }
panGestureDismissAnimator!.addCompletion { _ in
self.removeFromSuperview() self.removeFromSuperview()
} }
} else { } else {
self.removeFromSuperview() self.removeFromSuperview()
} }
panGestureDismissAnimator!.startAnimation()
} else { } else {
let duration = 0.5 let duration = 0.5
let velocity = distance * duration let velocity = distance * duration
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: .allowUserInteraction) { UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: .allowUserInteraction) {
self.transform = .identity self.transform = .identity
} }
panGestureDismissAnimator!.isReversed = true
panGestureDismissAnimator!.startAnimation()
} }
default: default:

View File

@ -77,6 +77,8 @@ extension ToastableViewController {
if animated { if animated {
toast.animateAppearance() toast.animateAppearance()
} else {
config.onAppear?(nil)
} }
if config.dismissOnScroll, if config.dismissOnScroll,