From 1131c1949a498a0e8eccf2b0257c9da9d9bc85ec Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Mon, 12 Dec 2022 11:21:43 +0000 Subject: [PATCH] Use a view controller for the timeline. (#359) This exposes lifecycle methods and tidies up the Coordinator. --- ElementX.xcodeproj/project.pbxproj | 12 +- .../Screens/RoomScreen/RoomScreenModels.swift | 6 - .../Screens/RoomScreen/View/RoomScreen.swift | 2 +- .../RoomScreen/View/TimelineTableView.swift | 477 ------------------ .../View/TimelineTableViewController.swift | 440 ++++++++++++++++ .../RoomScreen/View/TimelineView.swift | 92 ++++ 6 files changed, 541 insertions(+), 488 deletions(-) delete mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 951f3d397..dbc8b7d8d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -120,7 +120,6 @@ 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; }; 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; }; 3A64A93A651A3CB8774ADE8E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; - 3B0DF16FEA8481061C28A3BE /* TimelineTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */; }; 3B770CB4DED51CC362C66D47 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */; }; 3C549A0BF39F8A854D45D9FD /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; 3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; }; @@ -149,6 +148,7 @@ 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */; }; 4FF90E2242DBD596E1ED2E27 /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */; }; + 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 874A1842477895F199567BD7 /* TimelineView.swift */; }; 501304F26B52DF7024011B6C /* EmojiMartJSONLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */; }; 50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 518C93DC6516D3D018DE065F /* UNNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */; }; @@ -207,6 +207,7 @@ 6F2AB43A1EFAD8A97AF41A15 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = A7CA6F33C553805035C3B114 /* DeviceKit */; }; 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; }; 7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; }; + 702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */; }; 706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */; }; 7096FA3AC218D914E88BFB70 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BE37BE2FB86E00C8D150A /* AggregratedReaction.swift */; }; 719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; }; @@ -551,7 +552,6 @@ 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = ""; }; 167521635A1CC27624FCEB7F /* ServerSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModel.swift; sourceTree = ""; }; 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = AppIcon.pdf; sourceTree = ""; }; - 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableView.swift; sourceTree = ""; }; 1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = ""; }; 1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = ""; }; 179423E34EE846E048E49CBF /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = ""; }; @@ -753,6 +753,7 @@ 858F8D0B0D51CC41BAA18E24 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 85CB1DDCEE53B946D09DF4F6 /* bn-BD */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-BD"; path = "bn-BD.lproj/Localizable.strings"; sourceTree = ""; }; 873718F8BD17B778C5141C45 /* ta */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ta; path = ta.lproj/Localizable.strings; sourceTree = ""; }; + 874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; 878B7C1885486FB4BE41631D /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = iw; path = iw.lproj/Localizable.stringsdict; sourceTree = ""; }; 885D8C42DD17625B5261BEFF /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = ""; }; 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = ""; }; @@ -995,6 +996,7 @@ F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkBuilder.swift; sourceTree = ""; }; F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = ""; }; F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = ""; }; + F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = ""; }; FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = ""; }; FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = ""; }; @@ -1720,7 +1722,8 @@ 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */, B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, - 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */, + F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */, + 874A1842477895F199567BD7 /* TimelineView.swift */, A312471EA62EFB0FD94E60DC /* Style */, CCD48459CA34A1928EC7A26A /* Supplementary */, B7D3886505ECC85A06DA8258 /* Timeline */, @@ -3033,7 +3036,8 @@ ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */, 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */, FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */, - 3B0DF16FEA8481061C28A3BE /* TimelineTableView.swift in Sources */, + 702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */, + 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */, 7732B2F635626BE1C1CD92A4 /* UIActivityViewControllerWrapper.swift in Sources */, 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */, A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 22a3073dc..bbd393a1e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -63,12 +63,6 @@ struct RoomScreenViewState: BindableState { } let scrollToBottomPublisher = PassthroughSubject() - - /// Returns the opacity that the supplied timeline item's cell should be. - func opacity(for item: RoomTimelineViewProvider) -> CGFloat { - guard case let .reply(selectedItemID, _) = composerMode else { return 1.0 } - return selectedItemID == item.id ? 1.0 : 0.5 - } } struct RoomScreenViewStateBindings { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index ee647b446..cfa127143 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -32,7 +32,7 @@ struct RoomScreen: View { } var timeline: some View { - TimelineTableView() + TimelineView() .id(context.viewState.roomId) .environmentObject(context) .timelineStyle(settings.timelineStyle) diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift deleted file mode 100644 index e39b8099b..000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ /dev/null @@ -1,477 +0,0 @@ -// -// 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 wrapper that displays the timeline of a room. -struct TimelineTableView: UIViewRepresentable { - @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context - @Environment(\.timelineStyle) private var timelineStyle - - func makeUIView(context: Context) -> UITableView { - let tableView = UITableView(frame: .zero, style: .plain) - tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier) - tableView.separatorStyle = .none - tableView.allowsSelection = false - tableView.keyboardDismissMode = .onDrag - context.coordinator.tableView = tableView - viewModelContext.send(viewAction: .paginateBackwards) - return tableView - } - - func updateUIView(_ uiView: UITableView, context: Context) { - context.coordinator.update() - - if context.coordinator.timelineStyle != timelineStyle { - context.coordinator.timelineStyle = timelineStyle - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(viewModelContext: viewModelContext) - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject { - let viewModelContext: RoomScreenViewModel.Context - - var tableView: UITableView? { - didSet { - registerFrameObserver() - configureDataSource() - } - } - - var timelineStyle: TimelineStyle = .bubbles - 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() - } - } - - /// The mode of the message composer. This is used to render selected - /// items in the timeline when replying, editing etc. - var composerMode: RoomScreenComposerMode = .default { - didSet { - // Reload the visible items in order to update their opacity. - // Applying a snapshot won't work in this instance as the items don't change. - reloadVisibleItems() - } - } - - /// 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 displayReactionsMenuForItemId = "" { - didSet { - tableView?.reloadData() - } - } - - /// 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 - /// The observation token used to handle frame changes. - private var frameObserverToken: NSKeyValueObservation? - /// Yucky hack to fix some layouts where the scroll view doesn't make it to the bottom on keyboard appearance. - var keyboardWillShowLayout: LayoutDescriptor? - - init(viewModelContext: RoomScreenViewModel.Context) { - self.viewModelContext = viewModelContext - super.init() - - viewModelContext.viewState.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) - } - - /// Configures a diffable data source for the timeline's table view. - private func configureDataSource() { - guard let tableView else { return } - - 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 viewModelContext = self.viewModelContext - - cell.item = timelineItem - cell.contentConfiguration = UIHostingConfiguration { - VStack { - if viewModelContext.viewState.displayReactionsMenuForItemId == timelineItem.id { - TimelineItemReactionsMenuView(onEmojiSelected: { emoji in - viewModelContext.send(viewAction: .emojiTapped(emoji: emoji, itemId: timelineItem.id)) - }, onDisplayEmojiPicker: { - viewModelContext.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id)) - }) - } - timelineItem - .frame(maxWidth: .infinity, alignment: .leading) - .opacity(viewModelContext.viewState.opacity(for: timelineItem)) - .contextMenu { - viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) - } - .onAppear { - viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) - } - .onDisappear { - viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) - } - .environment(\.openURL, OpenURLAction { url in - viewModelContext.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - .onTapGesture(count: 2) { - viewModelContext.send(viewAction: .displayReactionsMenuForItemId(itemId: timelineItem.id)) - } - .onTapGesture { - viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) - } - } - } - .margins(.all, self.timelineStyle.rowInsets) - .minSize(height: 1) - - return cell - } - - tableView.delegate = self - } - - /// Adds an observer on the frame of the table view in order to keep the - /// last item visible when the keyboard is shown or the window resizes. - private func registerFrameObserver() { - // Remove the existing observer if necessary - frameObserverToken?.invalidate() - - frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in - self?.handleFrameChange() - } - } - - /// Updates the table's layout if necessary after the frame changed. - private nonisolated func handleFrameChange() { - Task { @MainActor in - guard self.composerMode == .default else { return } - - // The table view is yet to update its layout so layout() returns a - // description of the timeline before the frame change occurs. - let previousLayout = self.layout() - if previousLayout.isBottomVisible { - self.scrollToBottom(animated: false) - } - } - } - - /// Updates the table view's internal state from the view model's context. - func update() { - if timelineItems != viewModelContext.viewState.items { - timelineItems = viewModelContext.viewState.items - } - if isBackPaginating != viewModelContext.viewState.isBackPaginating { - isBackPaginating = viewModelContext.viewState.isBackPaginating - } - if composerMode != viewModelContext.viewState.composerMode { - composerMode = viewModelContext.viewState.composerMode - } - if displayReactionsMenuForItemId != viewModelContext.viewState.displayReactionsMenuForItemId { - displayReactionsMenuForItemId = viewModelContext.viewState.displayReactionsMenuForItemId - } - } - - /// 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() - - guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } - - if previousLayout.isBottomVisible { - scrollToBottom(animated: false) - } else if let pinnedItem = previousLayout.pinnedItem { - restoreScrollPosition(using: pinnedItem, and: snapshot) - } - } - - /// Reloads all of the visible timeline items. - /// - /// This only needs to be called when some state internal to this table view changes that - /// will affect the appearance of those items. Any updates to the items themselves should - /// use ``applySnapshot()`` which handles everything in the diffable data source. - private func reloadVisibleItems() { - guard let tableView, let visibleIndexPaths = tableView.indexPathsForVisibleRows, let dataSource else { return } - var snapshot = dataSource.snapshot() - snapshot.reloadItems(visibleIndexPaths.compactMap { dataSource.itemIdentifier(for: $0) }) - dataSource.apply(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 tableView, 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() { - guard let tableView else { return } - - let contentHeight = tableView.contentSize.height - (tableView.tableHeaderView?.frame.height ?? 0) - let height = tableView.visibleSize.height - contentHeight - - if height > 0 { - let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height)) - 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(of scrollView: UIScrollView) -> Bool { - scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.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 tableView, - 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 let tableView, - !isBackPaginating, - !hasPendingUpdates, - tableView.contentOffset.y < tableView.visibleSize.height * 2.0 - else { return } - - viewModelContext.send(viewAction: .paginateBackwards) - } - } -} - -// MARK: - UITableViewDelegate - -extension TimelineTableView.Coordinator: UITableViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let isAtBottom = isAtBottom(of: scrollView) - - if !viewModelContext.scrollToBottomButtonVisible, isAtBottom { - DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = true } - } else if viewModelContext.scrollToBottomButtonVisible, !isAtBottom { - DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = false } - } - - paginateBackwardsPublisher.send(()) - } - - // MARK: - ScrollViewAdapter - - // 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 TimelineTableView.Coordinator { - /// 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) - } -} - -// MARK: - Previews - -struct TimelineTableView_Previews: PreviewProvider { - static var previews: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), - mediaProvider: MockMediaProvider(), - roomName: "Preview room") - - NavigationView { - RoomScreen(context: viewModel.context) - } - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift new file mode 100644 index 000000000..81e86e526 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift @@ -0,0 +1,440 @@ +// +// 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() + } + } + + /// The mode of the message composer. This is used to render selected + /// items in the timeline when replying, editing etc. + var composerMode: RoomScreenComposerMode = .default { + didSet { + // Reload the visible items in order to update their opacity. + // Applying a snapshot won't work in this instance as the items don't change. + reloadVisibleItems() + } + } + + /// 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 displayReactionsMenuForItemId = "" { + didSet { + tableView.reloadData() + } + } + + var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)? + + @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 + 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 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 + let opacity = self.opacity(for: timelineItem) + let displayReactionsMenuForItemId = self.displayReactionsMenuForItemId + let contextMenuBuilder = self.contextMenuBuilder + + cell.item = timelineItem + cell.contentConfiguration = UIHostingConfiguration { + VStack { + if displayReactionsMenuForItemId == timelineItem.id { + TimelineItemReactionsMenuView { emoji in + coordinator.send(viewAction: .emojiTapped(emoji: emoji, itemId: timelineItem.id)) + } onDisplayEmojiPicker: { + coordinator.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id)) + } + } + + timelineItem + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(opacity) + .contextMenu { + contextMenuBuilder?(timelineItem.id) + } + .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: .displayReactionsMenuForItemId(itemId: timelineItem.id)) + } + .onTapGesture { + coordinator.send(viewAction: .itemTapped(id: timelineItem.id)) + } + } + } + .margins(.all, self.timelineStyle.rowInsets) + .minSize(height: 1) + + 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() + + guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } + + if previousLayout.isBottomVisible { + scrollToBottom(animated: false) + } else if let pinnedItem = previousLayout.pinnedItem { + restoreScrollPosition(using: pinnedItem, and: snapshot) + } + } + + /// Reloads all of the visible timeline items. + /// + /// This only needs to be called when some state internal to this table view changes that + /// will affect the appearance of those items. Any updates to the items themselves should + /// use ``applySnapshot()`` which handles everything in the diffable data source. + private func reloadVisibleItems() { + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows, let dataSource else { return } + var snapshot = dataSource.snapshot() + snapshot.reloadItems(visibleIndexPaths.compactMap { dataSource.itemIdentifier(for: $0) }) + dataSource.apply(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 contentHeight = tableView.contentSize.height - (tableView.tableHeaderView?.frame.height ?? 0) + let height = tableView.visibleSize.height - contentHeight + + if height > 0 { + let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height)) + 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 !isBackPaginating, + !hasPendingUpdates, + tableView.contentOffset.y < tableView.visibleSize.height * 2.0 + else { return } + + coordinator.send(viewAction: .paginateBackwards) + } + + /// Returns the opacity that the supplied timeline item's cell should be. + private func opacity(for item: RoomTimelineViewProvider) -> CGFloat { + guard case let .reply(selectedItemID, _) = composerMode else { return 1.0 } + return selectedItemID == item.id ? 1.0 : 0.5 + } +} + +// MARK: - UITableViewDelegate + +extension TimelineTableViewController: UITableViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let isAtBottom = isAtBottom() + + // Dispatches fix runtime warnings about making changes during a view update. + if !scrollToBottomButtonVisible, isAtBottom { + DispatchQueue.main.async { self.scrollToBottomButtonVisible = true } + } else if scrollToBottomButtonVisible, !isAtBottom { + DispatchQueue.main.async { self.scrollToBottomButtonVisible = false } + } + + paginateBackwardsPublisher.send(()) + } + + // 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) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift new file mode 100644 index 000000000..7ddaae723 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -0,0 +1,92 @@ +// +// 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 SwiftUI + +/// A table view wrapper that displays the timeline of a room. +struct TimelineView: UIViewControllerRepresentable { + @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context + @Environment(\.timelineStyle) private var timelineStyle + + func makeUIViewController(context: Context) -> TimelineTableViewController { + let tableViewController = TimelineTableViewController(coordinator: context.coordinator, + timelineStyle: timelineStyle, + scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible, + scrollToBottomPublisher: viewModelContext.viewState.scrollToBottomPublisher) + viewModelContext.send(viewAction: .paginateBackwards) + return tableViewController + } + + func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) { + context.coordinator.update(tableViewController: uiViewController, timelineStyle: timelineStyle) + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModelContext: viewModelContext) + } + + // MARK: - Coordinator + + @MainActor + class Coordinator { + let context: RoomScreenViewModel.Context + + init(viewModelContext: RoomScreenViewModel.Context) { + context = viewModelContext + } + + /// Updates the specified table view's properties from the current view state. + func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) { + if tableViewController.timelineStyle != timelineStyle { + tableViewController.timelineStyle = timelineStyle + } + if tableViewController.timelineItems != context.viewState.items { + tableViewController.timelineItems = context.viewState.items + } + if tableViewController.isBackPaginating != context.viewState.isBackPaginating { + tableViewController.isBackPaginating = context.viewState.isBackPaginating + } + if tableViewController.composerMode != context.viewState.composerMode { + tableViewController.composerMode = context.viewState.composerMode + } + if tableViewController.displayReactionsMenuForItemId != context.viewState.displayReactionsMenuForItemId { + tableViewController.displayReactionsMenuForItemId = context.viewState.displayReactionsMenuForItemId + } + + // Doesn't have an equatable conformance :( + tableViewController.contextMenuBuilder = context.viewState.contextMenuBuilder + } + + func send(viewAction: RoomScreenViewAction) { + context.send(viewAction: viewAction) + } + } +} + +// MARK: - Previews + +struct TimelineTableView_Previews: PreviewProvider { + static var previews: some View { + let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + timelineViewFactory: RoomTimelineViewFactory(), + mediaProvider: MockMediaProvider(), + roomName: "Preview room") + + NavigationView { + RoomScreen(context: viewModel.context) + } + } +}