New items indicator

This commit is contained in:
Justin Mazzocchi 2021-02-21 15:00:56 -08:00
parent 9274cd2615
commit beee1ff73b
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
11 changed files with 214 additions and 5 deletions

View File

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

View File

@ -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 = "<group>"; };
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadView.swift; sourceTree = "<group>"; };
D0CEC0E025E0BB9700FEF5A6 /* NewItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewItemsView.swift; sourceTree = "<group>"; };
D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionSection+Extensions.swift"; sourceTree = "<group>"; };
D0D2AC4625BCD289003D5DF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTableViewCell.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -11,6 +11,7 @@ public protocol CollectionService {
var canRefresh: Bool { get }
var title: AnyPublisher<String, Never> { 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<Never, Error>
@ -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<CollectionItem.Id, Error> { Empty().eraseToAnyPublisher() }

View File

@ -10,6 +10,7 @@ public struct NotificationsService {
public let sections: AnyPublisher<[CollectionSection], Error>
public let nextPageMaxId: AnyPublisher<String, Never>
public let navigationService: NavigationService
public let announcesNewItems = true
private let excludeTypes: Set<MastodonNotification.NotificationType>
private let mastodonAPIClient: MastodonAPIClient

View File

@ -13,6 +13,7 @@ public struct TimelineService {
public let accountIdsForRelationships: AnyPublisher<Set<Account.Id>, Never>
public let title: AnyPublisher<String, Never>
public let titleLocalizationComponents: AnyPublisher<[String], Never>
public let announcesNewItems = true
private let timeline: Timeline
private let mastodonAPIClient: MastodonAPIClient

View File

@ -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<LoadMoreView>()
private var cancellables = Set<AnyCancellable>()
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

View File

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

View File

@ -15,6 +15,7 @@ public protocol CollectionViewModel {
var searchScopeChanges: AnyPublisher<SearchScope, Never> { 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)

View File

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

View File

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

View File

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