diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 64583840..21cebcd1 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = ""; }; D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppListStyle.swift"; sourceTree = ""; }; + D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineJumpButton.swift; sourceTree = ""; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = ""; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = ""; }; @@ -1050,6 +1052,7 @@ D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */, D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */, + D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */, ); path = Timeline; sourceTree = ""; @@ -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 */, diff --git a/Tusker/Screens/FindInstanceViewController.swift b/Tusker/Screens/FindInstanceViewController.swift index b58ea99e..b4f49e46 100644 --- a/Tusker/Screens/FindInstanceViewController.swift +++ b/Tusker/Screens/FindInstanceViewController.swift @@ -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) } diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index 659018f3..8ee54fea 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -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) } diff --git a/Tusker/Screens/Timeline/TimelineJumpButton.swift b/Tusker/Screens/Timeline/TimelineJumpButton.swift new file mode 100644 index 00000000..8bda27f3 --- /dev/null +++ b/Tusker/Screens/Timeline/TimelineJumpButton.swift @@ -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 + } +} diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 33737966..12a5944c 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -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).self) { group -> [(String, Result)] 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 ?? "")") if !alwaysPrompt { - Task { - _ = await restoreState() - } + _ = await restoreState() } else { var config = ToastConfiguration(title: "Sync Position") config.edge = .top @@ -617,6 +596,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro toast.dismissToast(animated: true) } } + config.onAppear = { [unowned self] animator in + self.delegate?.timelineViewController(self, willShowSyncToastWith: animator) + } + config.onDismiss = { [unowned self] animator in + self.delegate?.timelineViewController(self, willDismissSyncToastWith: animator) + } showToast(configuration: config, animated: true) UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated") } @@ -654,30 +639,31 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return } self.disappearedAt = nil - if syncPositionIfNecessary(alwaysPrompt: false) { - // no-op - } else { - Task { - await checkPresent(jumpImmediately: false) + Task { + if await syncPositionIfNecessary(alwaysPrompt: false) { + // no-op + } else { + await checkPresent(jumpImmediately: false, animateImmediateJump: false) } } } - func checkPresent(jumpImmediately: Bool) async { - if case .idle = controller.state, - let presentItems = try? await loadInitial(), - !presentItems.isEmpty { - if jumpImmediately { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.statuses]) - snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses) - dataSource.apply(snapshot, animatingDifferences: false) { - self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false) - UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0))) - } - } else { - insertPresentItemsAndShowJumpToast(presentItems) + func checkPresent(jumpImmediately: Bool, animateImmediateJump: Bool) async { + guard case .idle = controller.state, + let presentItems = try? await loadInitial(), + !presentItems.isEmpty else { + return + } + if jumpImmediately { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.statuses]) + snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses) + dataSource.apply(snapshot, animatingDifferences: animateImmediateJump) { + self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: animateImmediateJump) + UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0))) } + } else { + insertPresentItemsAndShowJumpToast(presentItems) } } @@ -780,6 +766,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro self.showToast(configuration: config, animated: true) } } + config.onAppear = { [unowned self] animator in + self.delegate?.timelineViewController(self, willShowJumpToPresentToastWith: animator) + } + config.onDismiss = { [unowned self] animator in + self.delegate?.timelineViewController(self, willDismissJumpToPresentToastWith: animator) + } self.showToast(configuration: config, animated: true) } } diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 0c3d06f0..0d99df1a 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -13,12 +13,22 @@ import Combine class TimelinesPageViewController: SegmentedPageViewController { + 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() init(mastodonController: MastodonController) { @@ -46,30 +56,17 @@ class TimelinesPageViewController: SegmentedPageViewController: 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: UIView newController = existing } else { newController = pageProvider(page) + configureViewController(newController) pageControllers[page] = newController } @@ -130,7 +134,16 @@ class SegmentedPageViewController: 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 } diff --git a/Tusker/Views/Toast/ToastConfiguration.swift b/Tusker/Views/Toast/ToastConfiguration.swift index fbf95daf..625a31bc 100644 --- a/Tusker/Views/Toast/ToastConfiguration.swift +++ b/Tusker/Views/Toast/ToastConfiguration.swift @@ -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 diff --git a/Tusker/Views/Toast/ToastView.swift b/Tusker/Views/Toast/ToastView.swift index 32a01aa0..7bc38d65 100644 --- a/Tusker/Views/Toast/ToastView.swift +++ b/Tusker/Views/Toast/ToastView.swift @@ -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: diff --git a/Tusker/Views/Toast/ToastableViewController.swift b/Tusker/Views/Toast/ToastableViewController.swift index 25a65bd4..8445e454 100644 --- a/Tusker/Views/Toast/ToastableViewController.swift +++ b/Tusker/Views/Toast/ToastableViewController.swift @@ -77,6 +77,8 @@ extension ToastableViewController { if animated { toast.animateAppearance() + } else { + config.onAppear?(nil) } if config.dismissOnScroll,