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 */; };
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 */,

View File

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

View File

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

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 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()
}
} 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,32 +639,33 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return
}
self.disappearedAt = nil
if syncPositionIfNecessary(alwaysPrompt: false) {
Task {
if await syncPositionIfNecessary(alwaysPrompt: false) {
// no-op
} else {
Task {
await checkPresent(jumpImmediately: false)
await checkPresent(jumpImmediately: false, animateImmediateJump: false)
}
}
}
func checkPresent(jumpImmediately: Bool) async {
if case .idle = controller.state,
func checkPresent(jumpImmediately: Bool, animateImmediateJump: Bool) async {
guard case .idle = controller.state,
let presentItems = try? await loadInitial(),
!presentItems.isEmpty {
!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: false) {
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
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)
}
}
}
private func insertPresentItemsAndShowJumpToast(_ presentItems: [String]) {
var snapshot = dataSource.snapshot()
@ -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)
}
}

View File

@ -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
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)
}
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
}),
]
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()
}
}
}

View File

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

View File

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

View File

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

View File

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