metatext/View Controllers/TableViewController.swift

394 lines
15 KiB
Swift
Raw Normal View History

2020-08-21 02:29:01 +00:00
// Copyright © 2020 Metabolist. All rights reserved.
2020-10-20 06:41:10 +00:00
import AVKit
2020-08-21 02:29:01 +00:00
import Combine
2020-09-15 01:39:35 +00:00
import SafariServices
2020-09-05 02:31:43 +00:00
import SwiftUI
2020-09-01 07:33:49 +00:00
import ViewModels
2020-08-21 02:29:01 +00:00
2020-09-27 01:44:33 +00:00
class TableViewController: UITableViewController {
2020-09-23 01:00:56 +00:00
private let viewModel: CollectionViewModel
2020-10-07 00:31:29 +00:00
private let identification: Identification
2020-08-28 22:39:17 +00:00
private let loadingTableFooterView = LoadingTableFooterView()
2020-09-26 06:37:30 +00:00
private let webfingerIndicatorView = WebfingerIndicatorView()
2020-08-21 02:29:01 +00:00
private var cancellables = Set<AnyCancellable>()
2020-10-15 07:44:01 +00:00
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
2020-10-22 05:05:50 +00:00
private var transitionViewTag = -1
2020-08-21 02:29:01 +00:00
2020-10-07 21:06:26 +00:00
private lazy var dataSource: TableViewDataSource = {
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
2020-08-21 02:29:01 +00:00
}()
2020-10-07 00:31:29 +00:00
init(viewModel: CollectionViewModel, identification: Identification) {
2020-08-21 02:29:01 +00:00
self.viewModel = viewModel
2020-10-07 00:31:29 +00:00
self.identification = identification
2020-08-21 02:29:01 +00:00
super.init(style: .plain)
}
2020-08-28 22:39:17 +00:00
@available(*, unavailable)
2020-08-21 02:29:01 +00:00
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = dataSource
2020-08-28 22:39:17 +00:00
tableView.prefetchDataSource = self
2020-08-21 02:29:01 +00:00
tableView.cellLayoutMarginsFollowReadableWidth = true
2020-08-28 22:39:17 +00:00
tableView.tableFooterView = UIView()
2020-08-21 02:29:01 +00:00
2020-09-26 06:37:30 +00:00
view.addSubview(webfingerIndicatorView)
webfingerIndicatorView.translatesAutoresizingMaskIntoConstraints = false
2020-09-14 23:32:34 +00:00
2020-09-26 06:37:30 +00:00
NSLayoutConstraint.activate([
webfingerIndicatorView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
webfingerIndicatorView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor)
])
2020-08-28 22:39:17 +00:00
2020-09-26 06:37:30 +00:00
setupViewModelBindings()
2020-08-21 02:29:01 +00:00
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
2020-10-05 22:50:05 +00:00
viewModel.request(maxId: nil, minId: nil)
2020-08-21 02:29:01 +00:00
}
2020-10-15 07:44:01 +00:00
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateAutoplayViews()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
updateAutoplayViews()
}
2020-10-05 01:25:02 +00:00
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging else { return }
let up = scrollView.panGestureRecognizer.translation(in: scrollView.superview).y > 0
for loadMoreView in visibleLoadMoreViews {
loadMoreView.directionChanged(up: up)
}
2020-10-15 07:44:01 +00:00
updateAutoplayViews()
2020-10-05 01:25:02 +00:00
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
for loadMoreView in visibleLoadMoreViews {
loadMoreView.finalizeDirectionChange()
}
}
2020-08-21 02:29:01 +00:00
override func tableView(_ tableView: UITableView,
willDisplay cell: UITableViewCell,
forRowAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
2020-10-15 07:44:01 +00:00
var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItem: CGFloat]()
2020-08-21 02:29:01 +00:00
heightCache[item] = cell.frame.height
cellHeightCaches[tableView.frame.width] = heightCache
}
override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension }
return cellHeightCaches[tableView.frame.width]?[item] ?? UITableView.automaticDimension
}
override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
2020-10-05 07:50:59 +00:00
viewModel.canSelect(indexPath: indexPath)
2020-08-21 02:29:01 +00:00
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
2020-10-05 01:25:02 +00:00
tableView.deselectRow(at: indexPath, animated: true)
2020-10-05 07:50:59 +00:00
viewModel.select(indexPath: indexPath)
2020-08-21 02:29:01 +00:00
}
2020-08-28 22:39:17 +00:00
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
sizeTableHeaderFooterViews()
}
}
2020-10-15 07:44:01 +00:00
extension TableViewController {
static let autoplayableAttachmentsView = PassthroughSubject<StatusAttachmentsView?, Never>()
static let autoplayableAttachmentsViewNotification =
Notification.Name("com.metabolist.metatext.attachment-view-became-autoplayable")
}
2020-09-27 01:44:33 +00:00
extension TableViewController: UITableViewDataSourcePrefetching {
2020-08-28 22:39:17 +00:00
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
guard
2020-10-05 22:50:05 +00:00
let maxId = viewModel.nextPageMaxId,
2020-08-28 22:39:17 +00:00
let indexPath = indexPaths.last,
indexPath.section == dataSource.numberOfSections(in: tableView) - 1,
2020-09-24 01:33:13 +00:00
indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1
2020-08-28 22:39:17 +00:00
else { return }
2020-10-05 22:50:05 +00:00
viewModel.request(maxId: maxId, minId: nil)
2020-08-28 22:39:17 +00:00
}
2020-08-21 02:29:01 +00:00
}
2020-09-27 05:54:06 +00:00
extension TableViewController {
func sizeTableHeaderFooterViews() {
// https://useyourloaf.com/blog/variable-height-table-view-header/
if let headerView = tableView.tableHeaderView {
let size = headerView.systemLayoutSizeFitting(
CGSize(width: tableView.frame.width, height: .greatestFiniteMagnitude),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
if headerView.frame.size.height != size.height {
headerView.frame.size.height = size.height
tableView.tableHeaderView = headerView
tableView.layoutIfNeeded()
}
view.insertSubview(webfingerIndicatorView, aboveSubview: headerView)
}
if let footerView = tableView.tableFooterView {
let size = footerView.systemLayoutSizeFitting(
CGSize(width: tableView.frame.width, height: .greatestFiniteMagnitude),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
if footerView.frame.size.height != size.height {
footerView.frame.size.height = size.height
tableView.tableFooterView = footerView
tableView.layoutIfNeeded()
}
}
}
}
2020-10-20 06:41:10 +00:00
extension TableViewController: AVPlayerViewControllerDelegate {
func playerViewController(
_ playerViewController: AVPlayerViewController,
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
playerViewController.player?.isMuted = true
updateAutoplayViews()
}
}
2020-10-22 05:05:50 +00:00
extension TableViewController: ZoomAnimatorDelegate {
func transitionWillStartWith(zoomAnimator: ZoomAnimator) {
view.layoutIfNeeded()
guard let imageViewController = (presentedViewController as? ImageNavigationController)?.currentViewController
else { return }
if imageViewController.playerView.tag != 0 {
transitionViewTag = imageViewController.playerView.tag
} else if imageViewController.imageView.tag != 0 {
transitionViewTag = imageViewController.imageView.tag
}
}
func transitionDidEndWith(zoomAnimator: ZoomAnimator) {
}
2020-10-15 07:44:01 +00:00
2020-10-22 05:05:50 +00:00
func referenceView(for zoomAnimator: ZoomAnimator) -> UIView? {
tableView.visibleCells.compactMap { $0.viewWithTag(transitionViewTag) }.first
}
func referenceViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? {
guard let referenceView = referenceView(for: zoomAnimator) else { return nil }
return tabBarController?.view.convert(referenceView.frame, from: referenceView.superview)
}
}
private extension TableViewController {
2020-10-05 01:25:02 +00:00
var visibleLoadMoreViews: [LoadMoreView] {
tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView }
}
2020-09-26 06:37:30 +00:00
func setupViewModelBindings() {
viewModel.title.sink { [weak self] in self?.navigationItem.title = $0 }.store(in: &cancellables)
2020-10-07 21:06:26 +00:00
viewModel.updates.sink { [weak self] in self?.update($0) }.store(in: &cancellables)
2020-09-26 06:37:30 +00:00
2020-10-07 00:31:29 +00:00
viewModel.events.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.handle(event: $0) }
.store(in: &cancellables)
2020-09-26 06:37:30 +00:00
2020-10-14 00:03:01 +00:00
viewModel.expandAll.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.set(expandAllState: $0) }
2020-10-07 21:06:26 +00:00
.store(in: &cancellables)
2020-09-27 05:54:06 +00:00
viewModel.loading.receive(on: RunLoop.main).sink { [weak self] in
guard let self = self else { return }
2020-09-26 06:37:30 +00:00
2020-09-27 05:54:06 +00:00
self.tableView.tableFooterView = $0 ? self.loadingTableFooterView : UIView()
self.sizeTableHeaderFooterViews()
2020-09-26 06:37:30 +00:00
}
2020-09-27 05:54:06 +00:00
.store(in: &cancellables)
2020-10-06 23:12:11 +00:00
tableView.publisher(for: \.contentOffset)
.compactMap { [weak self] _ in self?.tableView.indexPathsForVisibleRows?.first }
.sink { [weak self] in self?.viewModel.viewedAtTop(indexPath: $0) }
.store(in: &cancellables)
2020-10-15 07:44:01 +00:00
Self.autoplayableAttachmentsView
.removeDuplicates()
.sink {
let notification = Notification(
name: Self.autoplayableAttachmentsViewNotification,
object: $0,
userInfo: nil)
NotificationCenter.default.post(notification)
}
.store(in: &cancellables)
2020-09-26 06:37:30 +00:00
}
2020-10-07 21:06:26 +00:00
func update(_ update: CollectionUpdate) {
2020-09-15 05:41:09 +00:00
var offsetFromNavigationBar: CGFloat?
if
2020-10-07 21:06:26 +00:00
let item = update.maintainScrollPosition,
2020-09-23 01:00:56 +00:00
let indexPath = dataSource.indexPath(for: item),
2020-09-15 05:41:09 +00:00
let navigationBar = navigationController?.navigationBar {
let navigationBarMaxY = tableView.convert(navigationBar.bounds, from: navigationBar).maxY
offsetFromNavigationBar = tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY
}
2020-10-14 00:03:01 +00:00
self.dataSource.apply(update.items.snapshot(), animatingDifferences: false) { [weak self] in
2020-09-15 05:41:09 +00:00
guard let self = self else { return }
2020-10-07 21:06:26 +00:00
if
let item = update.maintainScrollPosition,
let indexPath = self.dataSource.indexPath(for: item) {
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
2020-09-15 05:41:09 +00:00
2020-10-07 21:06:26 +00:00
if let offsetFromNavigationBar = offsetFromNavigationBar {
self.tableView.contentOffset.y -= offsetFromNavigationBar
2020-09-15 05:41:09 +00:00
}
}
2020-10-15 07:44:01 +00:00
self.updateAutoplayViews()
2020-09-15 05:41:09 +00:00
}
}
2020-10-07 00:31:29 +00:00
func handle(event: CollectionItemEvent) {
switch event {
case .ignorableOutput:
break
case let .share(url):
share(url: url)
2020-10-20 06:41:10 +00:00
case let .navigation(navigation):
2020-10-07 00:31:29 +00:00
switch navigation {
case let .collection(collectionService):
show(TableViewController(
viewModel: CollectionItemsViewModel(
collectionService: collectionService,
identification: identification),
identification: identification),
sender: self)
case let .profile(profileService):
show(ProfileViewController(
viewModel: ProfileViewModel(
profileService: profileService,
identification: identification),
identification: identification),
sender: self)
case let .url(url):
present(SFSafariViewController(url: url), animated: true)
case .webfingerStart:
webfingerIndicatorView.startAnimating()
case .webfingerEnd:
webfingerIndicatorView.stopAnimating()
}
2020-10-20 06:41:10 +00:00
case let .attachment(attachmentViewModel, statusViewModel):
present(attachmentViewModel: attachmentViewModel, statusViewModel: statusViewModel)
}
}
func present(attachmentViewModel: AttachmentViewModel, statusViewModel: StatusViewModel) {
switch attachmentViewModel.attachment.type {
case .audio, .video:
let playerViewController = AVPlayerViewController()
let player: AVPlayer
if attachmentViewModel.attachment.type == .video {
player = PlayerCache.shared.player(url: attachmentViewModel.attachment.url)
} else {
player = AVPlayer(url: attachmentViewModel.attachment.url)
}
playerViewController.delegate = self
playerViewController.player = player
present(playerViewController, animated: true) {
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
player.isMuted = false
player.play()
}
case .image, .gifv:
2020-10-21 08:07:13 +00:00
let imagePageViewController = ImagePageViewController(
initiallyVisible: attachmentViewModel,
statusViewModel: statusViewModel)
let imageNavigationController = ImageNavigationController(imagePageViewController: imagePageViewController)
2020-10-22 05:05:50 +00:00
imageNavigationController.transitionController.fromDelegate = self
transitionViewTag = attachmentViewModel.tag
2020-10-21 08:07:13 +00:00
present(imageNavigationController, animated: true)
2020-10-20 06:41:10 +00:00
case .unknown:
break
2020-10-07 00:31:29 +00:00
}
}
2020-10-14 00:03:01 +00:00
func set(expandAllState: ExpandAllState) {
switch expandAllState {
2020-10-07 21:06:26 +00:00
case .hidden:
navigationItem.rightBarButtonItem = nil
2020-10-14 00:03:01 +00:00
case .expand:
2020-10-07 21:06:26 +00:00
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: NSLocalizedString("status.show-more", comment: ""),
2020-10-14 00:03:01 +00:00
image: UIImage(systemName: "eye"),
primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleExpandAll() })
case .collapse:
2020-10-07 21:06:26 +00:00
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: NSLocalizedString("status.show-less", comment: ""),
2020-10-14 00:03:01 +00:00
image: UIImage(systemName: "eye.slash"),
primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleExpandAll() })
2020-10-07 21:06:26 +00:00
}
}
2020-09-14 23:32:34 +00:00
func share(url: URL) {
let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
present(activityViewController, animated: true, completion: nil)
}
2020-10-15 07:44:01 +00:00
func updateAutoplayViews() {
if let visibleView = navigationController?.visibleViewController?.view,
view.isDescendant(of: visibleView),
let superview = view.superview,
let attachmentsViewClosestToCenter = tableView.visibleCells
.compactMap({ ($0.contentView as? StatusView)?.attachmentsView })
.filter(\.shouldAutoplay)
.min(by: {
abs(superview.convert($0.frame, from: $0.superview).midY - view.frame.midY)
< abs(superview.convert($1.frame, from: $1.superview).midY - view.frame.midY)
}) {
Self.autoplayableAttachmentsView.send(attachmentsViewClosestToCenter)
} else {
Self.autoplayableAttachmentsView.send(nil)
}
}
2020-08-21 02:29:01 +00:00
}