// // Copyright 2022 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // import Combine import SwiftUI /// A table view cell that displays a timeline item in a room. The cell is intended /// to be configured to display a SwiftUI view and not use any UIKit. class TimelineItemCell: UITableViewCell { static let reuseIdentifier = "TimelineItemCell" var item: RoomTimelineViewProvider? override func prepareForReuse() { item = nil } } /// A table view controller that displays the timeline of a room. /// /// This class subclasses `UIViewController` as `UITableViewController` adds some /// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1). class TimelineTableViewController: UIViewController { let coordinator: TimelineView.Coordinator let tableView = UITableView(frame: .zero, style: .plain) var timelineStyle: TimelineStyle var timelineItems: [RoomTimelineViewProvider] = [] { didSet { guard !scrollAdapter.isScrolling.value else { // Delay updating until scrolling has stopped as programatic // changes to the scroll position kills any inertia. hasPendingUpdates = true return } applySnapshot() if timelineItems.isEmpty { paginateBackwardsPublisher.send() } } } /// The mode of the message composer. This is used to render selected /// items in the timeline when replying, editing etc. var composerMode: RoomScreenComposerMode = .default /// Whether or not the timeline has more messages to back paginate. var canBackPaginate = true /// Whether or not the timeline is waiting for more messages to be added to the top. var isBackPaginating = false { didSet { // Paginate again if the threshold hasn't been satisfied. paginateBackwardsPublisher.send(()) } } var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)? @Binding private var scrollToBottomButtonVisible: Bool /// The table's diffable data source. private var dataSource: UITableViewDiffableDataSource? private var cancellables: Set = [] /// The scroll view adapter used to detect whether scrolling is in progress. private let scrollAdapter = ScrollViewAdapter() /// A publisher used to throttle back pagination requests. /// /// Our view actions get wrapped in a `Task` so it is possible that a second call in /// quick succession can execute before ``isBackPaginating`` becomes `true`. private let paginateBackwardsPublisher = PassthroughSubject() /// Whether or not the ``timelineItems`` value should be applied when scrolling stops. private var hasPendingUpdates = false /// Yucky hack to fix some layouts where the scroll view doesn't make it to the bottom on keyboard appearance. private var keyboardWillShowLayout: LayoutDescriptor? /// Whether or not the view has been shown on screen yet. private var hasAppearedOnce = false init(coordinator: TimelineView.Coordinator, timelineStyle: TimelineStyle, scrollToBottomButtonVisible: Binding, scrollToBottomPublisher: PassthroughSubject) { self.coordinator = coordinator self.timelineStyle = timelineStyle _scrollToBottomButtonVisible = scrollToBottomButtonVisible super.init(nibName: nil, bundle: nil) tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier) tableView.separatorStyle = .none tableView.allowsSelection = false tableView.keyboardDismissMode = .onDrag tableView.backgroundColor = .element.background view.addSubview(tableView) scrollToBottomPublisher .sink { [weak self] _ in self?.scrollToBottom(animated: true) } .store(in: &cancellables) scrollAdapter.isScrolling .sink { [weak self] isScrolling in guard !isScrolling, let self, self.hasPendingUpdates else { return } // When scrolling has stopped, apply any pending updates. self.applySnapshot() self.hasPendingUpdates = false self.paginateBackwardsPublisher.send(()) } .store(in: &cancellables) paginateBackwardsPublisher .collect(.byTime(DispatchQueue.main, 0.1)) .sink { [weak self] _ in self?.paginateBackwardsIfNeeded() } .store(in: &cancellables) NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) .sink { [weak self] _ in guard let self else { return } self.keyboardWillShowLayout = self.layout() } .store(in: &cancellables) NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification) .sink { [weak self] _ in guard let self, let layout = self.keyboardWillShowLayout, layout.isBottomVisible else { return } self.scrollToBottom(animated: false) // Force the bottom to be visible as some timelines misbehave. } .store(in: &cancellables) configureDataSource() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not available.") } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard !hasAppearedOnce else { return } scrollToBottom(animated: false) hasAppearedOnce = true } override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) // Ensure the padding is correct before display. updateTopPadding() } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() guard tableView.frame.size != view.frame.size else { return } tableView.frame = CGRect(origin: .zero, size: view.frame.size) // Update the table's layout if necessary after the frame changed. updateTopPadding() guard composerMode == .default else { return } // The table view is yet to update its content so layout() returns a // description of the timeline before the frame change occurs. let previousLayout = layout() if previousLayout.isBottomVisible { scrollToBottom(animated: false) } } /// Configures a diffable data source for the timeline's table view. private func configureDataSource() { dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath) guard let self, let cell = cell as? TimelineItemCell else { return cell } // A local reference to avoid capturing self in the cell configuration. let coordinator = self.coordinator cell.item = timelineItem cell.contentConfiguration = UIHostingConfiguration { timelineItem .id(timelineItem.id) .frame(maxWidth: .infinity, alignment: .leading) .environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu .onAppear { coordinator.send(viewAction: .itemAppeared(id: timelineItem.id)) } .onDisappear { coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id)) } .environment(\.openURL, OpenURLAction { url in coordinator.send(viewAction: .linkClicked(url: url)) return .systemAction }) .onTapGesture(count: 2) { coordinator.send(viewAction: .itemDoubleTapped(id: timelineItem.id)) } .onTapGesture { coordinator.send(viewAction: .itemTapped(id: timelineItem.id)) } } .margins(.all, self.timelineStyle.rowInsets) .minSize(height: 1) .background(Color.clear) return cell } tableView.delegate = self } /// Updates the table view with the latest items from the ``timelineItems`` array. After /// updating the data, the table will be scrolled to the bottom if it was visible otherwise /// the scroll position will be updated to maintain the position of the last visible item. private func applySnapshot() { guard let dataSource else { return } let previousLayout = layout() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(timelineItems) dataSource.apply(snapshot, animatingDifferences: false) updateTopPadding() if previousLayout.isBottomVisible { scrollToBottom(animated: false) } else if let pinnedItem = previousLayout.pinnedItem { restoreScrollPosition(using: pinnedItem, and: snapshot) } } /// Returns a description of the current layout in order to update the /// scroll position after adding/updating items to the timeline. private func layout() -> LayoutDescriptor { guard let dataSource else { return LayoutDescriptor() } let snapshot = dataSource.snapshot() var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems) guard !snapshot.itemIdentifiers.isEmpty else { layout.isBottomVisible = true return layout } guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last, let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath) else { return layout } let bottomCellFrame = tableView.cellFrame(for: bottomItem) layout.pinnedItem = PinnedItem(id: bottomItem.id, position: .bottom, frame: bottomCellFrame) layout.isBottomVisible = bottomItem == snapshot.itemIdentifiers.last return layout } /// Updates the additional padding added to the top of the table (via a header) /// in order to fill the timeline from the bottom of the view upwards. private func updateTopPadding() { let headerHeight = tableView.tableHeaderView?.frame.height ?? 0 let contentHeight = tableView.contentSize.height - headerHeight let newHeight = max(0, tableView.visibleSize.height - contentHeight) guard newHeight != headerHeight else { return } if newHeight > 0 { let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: newHeight)) tableView.tableHeaderView = UIView(frame: frame) // Updating an existing view's height doesn't move the cells. } else { tableView.tableHeaderView = nil } } /// Whether or not the bottom of the scroll view is visible (with some small tolerance added). private func isAtBottom() -> Bool { tableView.contentOffset.y < (tableView.contentSize.height - tableView.visibleSize.height - 15) } /// Scrolls to the bottom of the timeline. private func scrollToBottom(animated: Bool) { guard let lastItem = timelineItems.last, let lastIndexPath = dataSource?.indexPath(for: lastItem) else { return } tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated) } /// Restores the position of the timeline using the supplied item and snapshot. private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot) { guard let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }), let indexPath = dataSource?.indexPath(for: item) else { return } // Scroll the item into view. tableView.scrollToRow(at: indexPath, at: pinnedItem.position, animated: false) guard let oldFrame = pinnedItem.frame, let newFrame = tableView.cellFrame(for: item) else { return } // Remove any unwanted offset that was added by scrollToRow. let deltaY = newFrame.maxY - oldFrame.maxY if deltaY != 0 { tableView.contentOffset.y += deltaY } } /// Checks whether or a backwards pagination is needed and requests one if so. /// /// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests. private func paginateBackwardsIfNeeded() { guard canBackPaginate, !isBackPaginating, !hasPendingUpdates, tableView.contentOffset.y < tableView.visibleSize.height * 2.0 else { return } coordinator.send(viewAction: .paginateBackwards) } } // MARK: - UITableViewDelegate extension TimelineTableViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { paginateBackwardsPublisher.send(()) // Dispatch to fix runtime warning about making changes during a view update. DispatchQueue.main.async { [weak self] in guard let self else { return } let isAtBottom = self.isAtBottom() // Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling. if self.scrollToBottomButtonVisible != isAtBottom { self.scrollToBottomButtonVisible = isAtBottom } } } // MARK: ScrollViewAdapter Methods // Required delegate methods are forwarded to the adapter so others can be implemented. func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrollAdapter.scrollViewWillBeginDragging(scrollView) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { scrollAdapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { scrollAdapter.scrollViewDidEndScrollingAnimation(scrollView) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollAdapter.scrollViewDidEndDecelerating(scrollView) } func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { scrollAdapter.scrollViewDidScrollToTop(scrollView) } } // MARK: - Layout Types extension TimelineTableViewController { /// The sections of the table view used in the diffable data source. enum TimelineSection { case main } /// A description of the timeline's layout. struct LayoutDescriptor { var numberOfItems = 0 var pinnedItem: PinnedItem? var isBottomVisible = false } /// An item that should have its position pinned after updates. struct PinnedItem { let id: String let position: UITableView.ScrollPosition let frame: CGRect? } } // MARK: - Cell Layout private extension UITableView { /// Returns the frame of the cell for a particular timeline item. func cellFrame(for item: RoomTimelineViewProvider) -> CGRect? { guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item == item }) else { return nil } return convert(timelineCell.frame, to: superview) } }