From beee1ff73bf0b9a79071375ab61fd80b6c345272 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sun, 21 Feb 2021 15:00:56 -0800 Subject: [PATCH] New items indicator --- Localizations/Localizable.strings | 3 + Metatext.xcodeproj/project.pbxproj | 6 +- .../Services/CollectionService.swift | 3 + .../Services/NotificationsService.swift | 1 + .../Services/TimelineService.swift | 1 + View Controllers/TableViewController.swift | 84 ++++++++++++- .../CollectionItemsViewModel.swift | 4 +- .../View Models/CollectionViewModel.swift | 1 + .../View Models/NavigationViewModel.swift | 4 +- .../View Models/ProfileViewModel.swift | 2 + Views/UIKit/NewItemsView.swift | 110 ++++++++++++++++++ 11 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 Views/UIKit/NewItemsView.swift diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 8935171..4cb2610 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -160,6 +160,7 @@ "main-navigation.conversations" = "Messages"; "metatext" = "Metatext"; "notification.signed-in-as-%@" = "Logged in as %@"; +"notification.new-items" = "New notifications"; "notifications.all" = "All"; "notifications.mentions" = "Mentions"; "ok" = "OK"; @@ -274,6 +275,8 @@ "status.delete-and-redraft.confirm.post" = "Are you sure you want to delete this post and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned."; "status.delete-and-redraft.confirm.toot" = "Are you sure you want to delete this toot and re-draft it? Favorites and boosts will be lost, and replies to the original toot will be orphaned."; "status.mute" = "Mute conversation"; +"status.new-items.post" = "New posts"; +"status.new-items.toot" = "New toots"; "status.pin" = "Pin on profile"; "status.pinned.post" = "Pinned post"; "status.pinned.toot" = "Pinned toot"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 9b22f60..c962d5d 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46F24F76169001EBDBB /* View+Extensions.swift */; }; D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; }; D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; }; + D0CEC0E125E0BB9700FEF5A6 /* NewItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC0E025E0BB9700FEF5A6 /* NewItemsView.swift */; }; D0D2AC3925BBEC0F003D5DF2 /* CollectionSection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */; }; D0D2AC4725BCD289003D5DF2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4625BCD289003D5DF2 /* TagView.swift */; }; D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */; }; @@ -376,6 +377,7 @@ D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadView.swift; sourceTree = ""; }; + D0CEC0E025E0BB9700FEF5A6 /* NewItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewItemsView.swift; sourceTree = ""; }; D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionSection+Extensions.swift"; sourceTree = ""; }; D0D2AC4625BCD289003D5DF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTableViewCell.swift; sourceTree = ""; }; @@ -463,6 +465,7 @@ D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */, D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */, D025B17D25C500BC001C69A8 /* CapsuleButton.swift */, + D0477F4525C72E50005C5368 /* CapsuleLabel.swift */, D0EA593F2522AC8700804347 /* CardView.swift */, D021A66F25C3E1F9008A0C0D /* Collection View Cells */, D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */, @@ -475,11 +478,11 @@ D05936DD25A937EC00754FDF /* EditThumbnailView.swift */, D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */, D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */, - D0477F4525C72E50005C5368 /* CapsuleLabel.swift */, D0BE97D625D0863E0057E161 /* ImagePastableTextView.swift */, D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */, + D0CEC0E025E0BB9700FEF5A6 /* NewItemsView.swift */, D035F8A825B9155900DC75ED /* NewStatusButtonView.swift */, D0FE1C8E253686F9003EF1EB /* PlayerView.swift */, D08B8D812544D80000B1EBEF /* PollOptionButton.swift */, @@ -1138,6 +1141,7 @@ D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, + D0CEC0E125E0BB9700FEF5A6 /* NewItemsView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */, D0477F4625C72E50005C5368 /* CapsuleLabel.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift index d965bca..cd7ef9c 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift @@ -11,6 +11,7 @@ public protocol CollectionService { var canRefresh: Bool { get } var title: AnyPublisher { get } var titleLocalizationComponents: AnyPublisher<[String], Never> { get } + var announcesNewItems: Bool { get } var navigationService: NavigationService { get } var positionTimeline: Timeline? { get } func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher @@ -31,6 +32,8 @@ extension CollectionService { public var titleLocalizationComponents: AnyPublisher<[String], Never> { Empty().eraseToAnyPublisher() } + public var announcesNewItems: Bool { false } + public var positionTimeline: Timeline? { nil } public func requestMarkerLastReadId() -> AnyPublisher { Empty().eraseToAnyPublisher() } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift index 8311dee..bc56caa 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift @@ -10,6 +10,7 @@ public struct NotificationsService { public let sections: AnyPublisher<[CollectionSection], Error> public let nextPageMaxId: AnyPublisher public let navigationService: NavigationService + public let announcesNewItems = true private let excludeTypes: Set private let mastodonAPIClient: MastodonAPIClient diff --git a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift index 983ae00..1d92077 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift @@ -13,6 +13,7 @@ public struct TimelineService { public let accountIdsForRelationships: AnyPublisher, Never> public let title: AnyPublisher public let titleLocalizationComponents: AnyPublisher<[String], Never> + public let announcesNewItems = true private let timeline: Timeline private let mastodonAPIClient: MastodonAPIClient diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 539c320..f615430 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -16,11 +16,14 @@ class TableViewController: UITableViewController { private let rootViewModel: RootViewModel? private let loadingTableFooterView = LoadingTableFooterView() private let webfingerIndicatorView = WebfingerIndicatorView() + private let newItemsView = NewItemsView() @Published private var loading = false private var visibleLoadMoreViews = Set() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private var shouldKeepPlayingVideoAfterDismissal = false + private var newItemsViewHiddenConstraint: NSLayoutConstraint? + private var newItemsViewVisibleConstraint: NSLayoutConstraint? private let insetBottom: Bool private weak var parentNavigationController: UINavigationController? @@ -67,11 +70,27 @@ class TableViewController: UITableViewController { view.addSubview(webfingerIndicatorView) webfingerIndicatorView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(newItemsView) + newItemsView.translatesAutoresizingMaskIntoConstraints = false + newItemsView.alpha = 0 + + newItemsViewHiddenConstraint = newItemsView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) + newItemsViewHiddenConstraint?.isActive = true + newItemsViewVisibleConstraint = newItemsView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, + constant: .defaultSpacing) + NSLayoutConstraint.activate([ webfingerIndicatorView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), - webfingerIndicatorView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor) + webfingerIndicatorView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + newItemsView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor) ]) + newItemsView.button.addAction(UIAction { [weak self] _ in + self?.newItemsTapped() + self?.hideNewItemsView() + }, + for: .touchUpInside) + setupViewModelBindings() viewModel.request(maxId: nil, minId: nil, search: nil) @@ -91,6 +110,10 @@ class TableViewController: UITableViewController { for loadMoreView in visibleLoadMoreViews { loadMoreView.directionChanged(up: up) } + + if up, newItemsView.alpha > 0 { + hideNewItemsView() + } } override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { @@ -313,7 +336,9 @@ extension TableViewController: ZoomAnimatorDelegate { extension TableViewController: ScrollableToTop { func scrollToTop(animated: Bool) { - tableView.scrollToTop(animated: animated) + guard !dataSource.snapshot().itemIdentifiers.isEmpty else { return } + + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) } } @@ -401,6 +426,7 @@ private extension TableViewController { let positionMaintenanceOffset: CGFloat let preUpdateContentOffsetY = tableView.contentOffset.y var setPreviousOffset = false + let firstItemId = dataSource.snapshot().itemIdentifiers.first?.itemId if let itemId = update.maintainScrollPositionItemId, let indexPath = dataSource.indexPath(itemId: itemId) { @@ -430,6 +456,16 @@ private extension TableViewController { self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) self.tableView.contentOffset.y -= positionMaintenanceOffset + + if self.viewModel.announcesNewItems, + let firstItemId = firstItemId, + let newFirstItem = self.dataSource.snapshot().itemIdentifiers.first, + let newFirstItemId = newFirstItem.itemId, + newFirstItemId > firstItemId { + DispatchQueue.main.async { + self.announceNewItems(newestItem: newFirstItem) + } + } } else if setPreviousOffset { self.tableView.contentOffset.y = preUpdateContentOffsetY } @@ -723,5 +759,49 @@ private extension TableViewController { viewModel.request(maxId: nil, minId: nil, search: nil) } } + + func newItemsTapped() { + scrollToTop(animated: true) + } + + func announceNewItems(newestItem: CollectionItem) { + switch newestItem { + case .status: + switch viewModel.identityContext.appPreferences.statusWord { + case .toot: + newItemsView.title = NSLocalizedString("status.new-items.toot", comment: "") + case .post: + newItemsView.title = NSLocalizedString("status.new-items.post", comment: "") + } + case .notification: + newItemsView.title = NSLocalizedString("notification.new-items", comment: "") + default: + return + } + + newItemsView.layoutIfNeeded() + + UIView.animate(withDuration: .zeroIfReduceMotion(.defaultAnimationDuration), + delay: 0, + usingSpringWithDamping: 0.5, + initialSpringVelocity: 5, + options: .curveEaseInOut) { + self.newItemsView.alpha = 1 + self.newItemsViewHiddenConstraint?.isActive = false + self.newItemsViewVisibleConstraint?.isActive = true + self.view.layoutIfNeeded() + } completion: { _ in + + } + } + + func hideNewItemsView() { + UIView.animate(withDuration: .zeroIfReduceMotion(.defaultAnimationDuration)) { + self.newItemsView.alpha = 0 + self.newItemsViewHiddenConstraint?.isActive = true + self.newItemsViewVisibleConstraint?.isActive = false + self.view.layoutIfNeeded() + } + } } // swiftlint:enable file_length diff --git a/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift index 9d95ec2..0fc1b51 100644 --- a/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift @@ -125,6 +125,8 @@ extension CollectionItemsViewModel: CollectionViewModel { public var canRefresh: Bool { collectionService.canRefresh } + public var announcesNewItems: Bool { collectionService.announcesNewItems } + public func request(maxId: String? = nil, minId: String? = nil, search: Search?) { collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId, search: search) .receive(on: DispatchQueue.main) @@ -409,7 +411,7 @@ private extension CollectionItemsViewModel { return configuration.isContextParent // Maintain scroll position of parent after initial load of context }) { return contextParent.itemId - } else if collectionService is TimelineService { + } else if collectionService is TimelineService || collectionService is NotificationsService { let difference = newItems.difference(from: items) if let lastSelectedLoadMore = lastSelectedLoadMore { diff --git a/ViewModels/Sources/ViewModels/View Models/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/View Models/CollectionViewModel.swift index 6528926..6b3f7a6 100644 --- a/ViewModels/Sources/ViewModels/View Models/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/CollectionViewModel.swift @@ -15,6 +15,7 @@ public protocol CollectionViewModel { var searchScopeChanges: AnyPublisher { get } var nextPageMaxId: String? { get } var canRefresh: Bool { get } + var announcesNewItems: Bool { get } func request(maxId: String?, minId: String?, search: Search?) func requestNextPage(fromIndexPath indexPath: IndexPath) func viewedAtTop(indexPath: IndexPath) diff --git a/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift index 9dcb3ad..edd755f 100644 --- a/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift @@ -167,7 +167,9 @@ public extension NavigationViewModel { collectionService: identityContext.service.notificationsService(excludeTypes: excludeTypes), identityContext: identityContext) - viewModel.request(maxId: nil, minId: nil, search: nil) + if excludeTypes.isEmpty { + viewModel.request(maxId: nil, minId: nil, search: nil) + } return viewModel } diff --git a/ViewModels/Sources/ViewModels/View Models/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/View Models/ProfileViewModel.swift index 061e248..f615361 100644 --- a/ViewModels/Sources/ViewModels/View Models/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/ProfileViewModel.swift @@ -139,6 +139,8 @@ extension ProfileViewModel: CollectionViewModel { public var canRefresh: Bool { collectionViewModel.value.canRefresh } + public var announcesNewItems: Bool { collectionViewModel.value.canRefresh } + public func request(maxId: String?, minId: String?, search: Search?) { if case .statuses = collection, maxId == nil { profileService.fetchPinnedStatuses() diff --git a/Views/UIKit/NewItemsView.swift b/Views/UIKit/NewItemsView.swift new file mode 100644 index 0000000..6de2417 --- /dev/null +++ b/Views/UIKit/NewItemsView.swift @@ -0,0 +1,110 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit + +final class NewItemsView: UIView { + let button = UIButton() + + public var title: String? { + get { label.text } + set { + label.text = newValue + button.accessibilityLabel = newValue + } + } + + private let label = UILabel() + private let blurView: UIVisualEffectView + private let vibrancyView: UIVisualEffectView + + init() { + let blurEffect = UIBlurEffect(style: .systemChromeMaterial) + blurView = UIVisualEffectView(effect: blurEffect) + vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label)) + + super.init(frame: .zero) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + let cornerRadius = bounds.height / 2 + + layer.cornerRadius = cornerRadius + blurView.layer.cornerRadius = cornerRadius + } +} + +private extension NewItemsView { + // swiftlint:disable:next function_body_length + func initialSetup() { + backgroundColor = .clear + layer.shadowOffset = .zero + layer.shadowRadius = .defaultShadowRadius + layer.shadowOpacity = .defaultShadowOpacity + + addSubview(blurView) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.clipsToBounds = true + blurView.contentView.addSubview(vibrancyView) + vibrancyView.translatesAutoresizingMaskIntoConstraints = false + + let stackView = UIStackView() + + vibrancyView.contentView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = .defaultSpacing + + let arrowImage = UIImage(systemName: "arrow.up", + withConfiguration: UIImage.SymbolConfiguration(weight: .bold)) + + stackView.addArrangedSubview(UIImageView(image: arrowImage)) + stackView.addArrangedSubview(label) + label.adjustsFontForContentSizeCategory = true + label.font = .preferredFont(forTextStyle: .headline) + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + + let touchStartAction = UIAction { [weak self] _ in self?.alpha = 0.75 } + + button.addAction(touchStartAction, for: .touchDown) + button.addAction(touchStartAction, for: .touchDragEnter) + + let touchEndAction = UIAction { [weak self] _ in self?.alpha = 1 } + + button.addAction(touchEndAction, for: .touchDragExit) + button.addAction(touchEndAction, for: .touchUpInside) + button.addAction(touchEndAction, for: .touchUpOutside) + button.addAction(touchEndAction, for: .touchCancel) + + NSLayoutConstraint.activate([ + blurView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurView.topAnchor.constraint(equalTo: topAnchor), + blurView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurView.bottomAnchor.constraint(equalTo: bottomAnchor), + button.leadingAnchor.constraint(equalTo: leadingAnchor), + button.topAnchor.constraint(equalTo: topAnchor), + button.trailingAnchor.constraint(equalTo: trailingAnchor), + button.bottomAnchor.constraint(equalTo: bottomAnchor), + vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor), + vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor), + vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor), + vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: vibrancyView.contentView.leadingAnchor, + constant: .defaultSpacing), + stackView.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor, constant: .defaultSpacing), + stackView.trailingAnchor.constraint(equalTo: vibrancyView.contentView.trailingAnchor, + constant: -.defaultSpacing * 2), + stackView.bottomAnchor.constraint(equalTo: vibrancyView.contentView.bottomAnchor, + constant: -.defaultSpacing) + ]) + } +}