New items indicator
This commit is contained in:
parent
9274cd2615
commit
beee1ff73b
|
@ -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";
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
])
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue