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 */; };
|
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 */,
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 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,32 +639,33 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.disappearedAt = nil
|
self.disappearedAt = nil
|
||||||
if syncPositionIfNecessary(alwaysPrompt: false) {
|
Task {
|
||||||
|
if await syncPositionIfNecessary(alwaysPrompt: false) {
|
||||||
// no-op
|
// no-op
|
||||||
} else {
|
} else {
|
||||||
Task {
|
await checkPresent(jumpImmediately: false, animateImmediateJump: false)
|
||||||
await checkPresent(jumpImmediately: 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 {
|
||||||
|
return
|
||||||
|
}
|
||||||
if jumpImmediately {
|
if jumpImmediately {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: animateImmediateJump) {
|
||||||
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
|
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)))
|
UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
insertPresentItemsAndShowJumpToast(presentItems)
|
insertPresentItemsAndShowJumpToast(presentItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func insertPresentItemsAndShowJumpToast(_ presentItems: [String]) {
|
private func insertPresentItemsAndShowJumpToast(_ presentItems: [String]) {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
Task {
|
|
||||||
await vc.checkPresent(jumpImmediately: true)
|
|
||||||
}
|
}
|
||||||
return true
|
let jumpItem = UIBarButtonItem(customView: jumpButton)
|
||||||
}),
|
jumpItem.accessibilityAttributedLabel = Self.jumpToPresentTitle
|
||||||
UIAccessibilityCustomAction(name: "Jump to Sync Position", actionHandler: { [unowned self] _ in
|
navigationItem.leftBarButtonItem = jumpItem
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -77,6 +77,8 @@ extension ToastableViewController {
|
||||||
|
|
||||||
if animated {
|
if animated {
|
||||||
toast.animateAppearance()
|
toast.animateAppearance()
|
||||||
|
} else {
|
||||||
|
config.onAppear?(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.dismissOnScroll,
|
if config.dismissOnScroll,
|
||||||
|
|
Loading…
Reference in New Issue