From e1ea529309d949837a45f8ef797a29b986bd8c80 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 24 Nov 2022 16:31:39 +0000 Subject: [PATCH 01/10] Use a collection view for the timeline. --- ElementX.xcodeproj/project.pbxproj | 12 +- .../en.lproj/Untranslated.strings | 1 + .../Generated/Strings+Untranslated.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 4 +- .../RoomScreen/RoomScreenViewModel.swift | 24 +- .../View/TimelineCollectionView.swift | 265 ++++++++++++++++++ .../RoomScreen/View/TimelineItemList.swift | 173 ++---------- .../RoomScreen/View/TimelineView.swift | 15 +- .../Services/Media/MediaSourceProxy.swift | 15 +- .../AggregratedReaction.swift | 2 +- .../MessageTimelineItem.swift | 2 +- .../EventBasedTimelineItemProtocol.swift | 2 +- .../Items/EmoteRoomTimelineItem.swift | 2 +- .../Items/EncryptedRoomTimelineItem.swift | 4 +- .../Items/FileRoomTimelineItem.swift | 2 +- .../Items/ImageRoomTimelineItem.swift | 2 +- .../Items/NoticeRoomTimelineItem.swift | 2 +- .../Items/RedactedRoomTimelineItem.swift | 2 +- .../Items/RoomTimelineItemProperties.swift | 2 +- .../Items/SeparatorRoomTimelineItem.swift | 2 +- .../Items/TextRoomTimelineItem.swift | 2 +- .../Items/VideoRoomTimelineItem.swift | 2 +- .../RoomTimelineViewProvider.swift | 2 +- 23 files changed, 360 insertions(+), 181 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d60dc539ec..c7f9e4b2e5 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -255,6 +255,7 @@ 8F2FAA98457750D9D664136F /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; + 9158AAEC292F83CF00941CAE /* TimelineCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9158AAEB292F83CF00941CAE /* TimelineCollectionView.swift */; }; 91DFCB641FBA03EE2DA0189E /* FilePreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB27E1BE894F9F9F0134372 /* FilePreviewScreen.swift */; }; 9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; }; 93875ADD456142D20823ED24 /* ServerSelectionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */; }; @@ -733,17 +734,18 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; - 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; + 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = ""; }; 9080CDD3881D0D1B2F280A7C /* MockUserNotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserNotificationController.swift; sourceTree = ""; }; + 9158AAEB292F83CF00941CAE /* TimelineCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineCollectionView.swift; sourceTree = ""; }; 9238D3A3A00F45E841FE4EFF /* DebugScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugScreen.swift; sourceTree = ""; }; 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = ""; }; 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = ""; }; @@ -929,7 +931,7 @@ EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1629,6 +1631,7 @@ 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */, B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */, 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */, + 9158AAEB292F83CF00941CAE /* TimelineCollectionView.swift */, A627CE1429374617334FA5E9 /* TimelineScrollView.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, 874A1842477895F199567BD7 /* TimelineView.swift */, @@ -2714,6 +2717,7 @@ files = ( 098CE03C6CC71A31F263FA33 /* ActivityCoordinator.swift in Sources */, 7096FA3AC218D914E88BFB70 /* AggregratedReaction.swift in Sources */, + 9158AAEC292F83CF00941CAE /* TimelineCollectionView.swift in Sources */, A50849766F056FD1DB942DEA /* AlertInfo.swift in Sources */, 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */, A371629728E597C5FCA3C2B2 /* Analytics.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 2ac82259d4..586fba3219 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -16,6 +16,7 @@ "room_timeline_replying_to" = "Replying to %@"; "room_timeline_editing" = "Editing"; +"room_timeline_syncing" = "Syncing"; "session_verification_banner_title" = "Help keep your messages secure"; "session_verification_banner_message" = "Looks like you’re using a new device. Verify its you."; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 8b413cb155..8565ec9f2e 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -38,6 +38,8 @@ extension ElementL10n { public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description") /// Plain Timeline public static let roomTimelineStylePlainLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_plain_long_description") + /// Syncing + public static let roomTimelineSyncing = ElementL10n.tr("Untranslated", "room_timeline_syncing") /// Would you like to submit a bug report? public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message") /// You took a screenshot diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 2795e437e1..3c9b7e0518 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -14,7 +14,7 @@ // limitations under the License. // -import Foundation +import Combine import UIKit enum RoomScreenViewModelAction { @@ -56,6 +56,8 @@ struct RoomScreenViewState: BindableState { var sendButtonDisabled: Bool { bindings.composerText.count == 0 } + + let loadPreviousPagePublisher = PassthroughSubject() } struct RoomScreenViewStateBindings { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 8ec0a4ad6d..d86b484e48 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -60,14 +60,26 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem) case .startedBackPaginating: self.state.isBackPaginating = true + ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: "pagination", + type: .toast, + title: ElementL10n.roomTimelineSyncing, + persistent: true)) case .finishedBackPaginating: self.state.isBackPaginating = false + ServiceLocator.shared.userNotificationController.retractNotificationWithId("pagination") } } .store(in: &cancellables) state.contextMenuBuilder = buildContexMenuForItemId(_:) + state.loadPreviousPagePublisher + .collect(.byTime(DispatchQueue.main, 0.1)) + .sink { [weak self] _ in + Task { await self?.paginateBackwards() } + } + .store(in: &cancellables) + buildTimelineViews() if let roomAvatarUrl { @@ -87,10 +99,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol override func process(viewAction: RoomScreenViewAction) async { switch viewAction { case .loadPreviousPage: - switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) { - default: - #warning("Treat errors") - } + await paginateBackwards() case .itemAppeared(let id): await timelineController.processItemAppearance(id) case .itemDisappeared(let id): @@ -118,6 +127,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } // MARK: - Private + + private func paginateBackwards() async { + switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) { + default: + #warning("Treat errors") + } + } private func itemTapped(with itemId: String) async { state.showLoading = true diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift new file mode 100644 index 0000000000..edccc1e7c7 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift @@ -0,0 +1,265 @@ +// +// TimelineListView.swift +// UITimeline +// +// Created by Doug on 23/11/2022. +// + +import Combine +import SwiftUI + +class TimelineItemSwiftUICell: UICollectionViewCell { + #warning("Do more in here") + var timelineItem: RoomTimelineViewProvider? +} + +struct TimelineCollectionView: UIViewRepresentable { + @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context + @Environment(\.timelineStyle) private var timelineStyle + + #warning("Should this come via the context like loadPreviousPage???") + let scrollToBottomPublisher: PassthroughSubject + @Binding var scrollToBottomButtonVisible: Bool + + func makeUIView(context: Context) -> UICollectionView { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: context.coordinator.makeLayout()) + collectionView.keyboardDismissMode = .onDrag + context.coordinator.collectionView = collectionView + context.coordinator.loadPreviousPagePublisher = viewModelContext.viewState.loadPreviousPagePublisher + return collectionView + } + + func updateUIView(_ uiView: UICollectionView, context: Context) { + if context.coordinator.timelineItems != viewModelContext.viewState.items { + context.coordinator.timelineItems = viewModelContext.viewState.items + } + if context.coordinator.isBackPaginating != viewModelContext.viewState.isBackPaginating { + context.coordinator.isBackPaginating = viewModelContext.viewState.isBackPaginating + } + if context.coordinator.timelineStyle != timelineStyle { + context.coordinator.timelineStyle = timelineStyle + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModelContext: viewModelContext, + scrollToBottomPublisher: scrollToBottomPublisher, + scrollToBottomButtonVisible: $scrollToBottomButtonVisible) + } + + // MARK: - Coordinator + + @MainActor + class Coordinator: NSObject { + let viewModelContext: RoomScreenViewModel.Context + + var collectionView: UICollectionView? { + didSet { + configureDataSource() + } + } + + var timelineStyle: TimelineStyle = .bubbles + var timelineItems: [RoomTimelineViewProvider] = [] { + didSet { + applySnapshot() + } + } + + var isBackPaginating = false { + didSet { + // TODO: Paginate again if needed + } + } + + var loadPreviousPagePublisher = PassthroughSubject() + var cancellables: Set = [] + @Binding var scrollToBottomButtonVisible: Bool + + private var dataSource: UICollectionViewDiffableDataSource? + + init(viewModelContext: RoomScreenViewModel.Context, + scrollToBottomPublisher: PassthroughSubject, + scrollToBottomButtonVisible: Binding) { + self.viewModelContext = viewModelContext + _scrollToBottomButtonVisible = scrollToBottomButtonVisible + super.init() + + scrollToBottomPublisher + .sink { [weak self] _ in + self?.scrollToBottom(animated: true) + } + .store(in: &cancellables) + } + + func makeLayout() -> UICollectionViewCompositionalLayout { + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) + configuration.showsSeparators = false + return UICollectionViewCompositionalLayout.list(using: configuration) + } + + private func configureDataSource() { + guard let collectionView else { return } + let cellRegistration = UICollectionView.CellRegistration { cell, indexPath, timelineItem in + cell.timelineItem = timelineItem + } + + dataSource = .init(collectionView: collectionView) { collectionView, indexPath, timelineItem in + #warning("Do we need a weak self here???") + let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: timelineItem) + cell.contentConfiguration = UIHostingConfiguration { + timelineItem + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) + } + .opacity(self.opacityForItem(timelineItem)) + .onAppear { + self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) + } + .onDisappear { + self.viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) + } + .environment(\.openURL, OpenURLAction { url in + self.viewModelContext.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) + .onTapGesture { + self.viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) + } + } + .margins(.all, self.timelineStyle.rowInsets) + + return cell + } + + collectionView.delegate = self + } + + func applySnapshot() { + let previousLayout = layout() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(timelineItems) + dataSource?.apply(snapshot, animatingDifferences: false) + + guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } + + if previousLayout.isBottomVisible || previousLayout.isEmpty { + let animated = !previousLayout.isEmpty + scrollToBottom(animated: false) + } else if let pinnedItem = previousLayout.pinnedItem, + let collectionView, + let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }), + let indexPath = dataSource?.indexPath(for: item) { + collectionView.scrollToItem(at: indexPath, at: pinnedItem.position, animated: false) + } + } + + func layout() -> LayoutDescriptor { + guard let collectionView, let dataSource else { return LayoutDescriptor() } + + let snapshot = dataSource.snapshot() + var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems, contentSize: collectionView.contentSize) + + guard !snapshot.itemIdentifiers.isEmpty else { + layout.isEmpty = true + return layout + } + + if let firstVisibleIndexPath = collectionView.indexPathsForVisibleItems.first, + let firstVisibleItem = dataSource.itemIdentifier(for: firstVisibleIndexPath) { + layout.pinnedItem = PinnedItem(id: firstVisibleItem.id, position: .top) + } + + if let lastItem = snapshot.itemIdentifiers.last, + let lastIndexPath = dataSource.indexPath(for: lastItem) { + layout.isBottomVisible = collectionView.indexPathsForVisibleItems.contains(lastIndexPath) + } + + return layout + } + + func scrollToBottom(animated: Bool) { + guard let lastItem = timelineItems.last, + let lastIndexPath = dataSource?.indexPath(for: lastItem) + else { return } + + collectionView?.scrollToItem(at: lastIndexPath, at: .bottom, animated: animated) + } + + private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { + guard case let .reply(selectedItemId, _) = viewModelContext.viewState.composerMode else { + return 1.0 + } + + return selectedItemId == item.id ? 1.0 : 0.5 + } + + // TODO: Handle frame changes. + } + + enum TimelineSection { case main } + + struct LayoutDescriptor { + var numberOfItems = 0 + var pinnedItem: PinnedItem? + var isBottomVisible = false + var isEmpty = false + var contentSize: CGSize = .zero + } + + struct PinnedItem { + let id: String + let position: UICollectionView.ScrollPosition + } +} + +// MARK: - UICollectionViewDelegate + +extension TimelineCollectionView.Coordinator: UICollectionViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let isAtBottom = isAtBottom(of: scrollView) + + if !scrollToBottomButtonVisible, isAtBottom { + DispatchQueue.main.async { self.scrollToBottomButtonVisible = true } + } else if scrollToBottomButtonVisible, !isAtBottom { + DispatchQueue.main.async { self.scrollToBottomButtonVisible = false } + } + + if scrollView.contentOffset.y < paginationThreshold(for: scrollView), !isBackPaginating { + loadPreviousPagePublisher.send(()) + } + } + + func isAtBottom(of scrollView: UIScrollView) -> Bool { + scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.visibleSize.height - 15) + } + + func paginationThreshold(for scrollView: UIScrollView) -> Double { + scrollView.visibleSize.height * 2.0 + } +} + + +// MARK: - Previews + +struct TimelineCollectionView_Previews: PreviewProvider { + static var previews: some View { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: 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/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift index f4ae01998c..ef12c65187 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -18,161 +18,52 @@ import Combine import SwiftUI struct TimelineItemList: View { - @ObservedObject private var settings = ElementSettings.shared - @State private var timelineItems: [RoomTimelineViewProvider] = [] - @State private var viewFrame: CGRect = .zero - @State private var pinnedItem: PinnedItem? - - @Binding var visibleEdges: [VerticalEdge] - /// The last known value of the visible edges. This is stored because `visibleEdges` - /// updates at the same time as the `viewFrame` but we need to know the previous - /// value when the keyboard appears to determine whether to scroll to the bottom. - @State private var cachedVisibleEdges: [VerticalEdge] = [] @EnvironmentObject var context: RoomScreenViewModel.Context let scrollToBottomPublisher: PassthroughSubject var body: some View { - ScrollViewReader { scrollView in - TimelineScrollView(visibleEdges: $visibleEdges) { - // The scroll view already contains a VStack so simply provide the content to fill it. - - ProgressView() - .frame(maxWidth: .infinity) - .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) - .animation(.elementDefault, value: context.viewState.isBackPaginating) - - ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in - item - .contextMenu { - context.viewState.contextMenuBuilder?(item.id) - .id(item.id) - } - .opacity(opacityForItem(item)) - .padding(settings.timelineStyle.rowInsets) - .onAppear { - context.send(viewAction: .itemAppeared(id: item.id)) - } - .onDisappear { - context.send(viewAction: .itemDisappeared(id: item.id)) - } - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - .onTapGesture { - context.send(viewAction: .itemTapped(id: item.id)) - } - } - } - .onChange(of: visibleEdges) { edges in - cachedVisibleEdges = edges - // Paginate when the top becomes visible - guard edges.contains(.top) else { return } - requestBackPagination() - } - .onChange(of: context.viewState.isBackPaginating) { isBackPaginating in - guard !isBackPaginating else { return } - - // Repeat the pagination if the top edge is still visible. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - guard visibleEdges.contains(.top) else { return } - requestBackPagination() - } - } - .onChange(of: pinnedItem) { item in - guard let item else { return } - - if item.animated { - withAnimation(Animation.elementDefault) { - scrollView.scrollTo(item.id, anchor: item.anchor) + TimelineScrollView(visibleEdges: .constant([])) { + // The scroll view already contains a VStack so simply provide the content to fill it. + + ProgressView() + .frame(maxWidth: .infinity) + .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) + .animation(.elementDefault, value: context.viewState.isBackPaginating) + + ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in + item + .contextMenu { + context.viewState.contextMenuBuilder?(item.id) + .id(item.id) + } + .opacity(opacityForItem(item)) + .onAppear { + context.send(viewAction: .itemAppeared(id: item.id)) + } + .onDisappear { + context.send(viewAction: .itemDisappeared(id: item.id)) + } + .environment(\.openURL, OpenURLAction { url in + context.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) + .onTapGesture { + context.send(viewAction: .itemTapped(id: item.id)) } - } else { - scrollView.scrollTo(item.id, anchor: item.anchor) - } - - pinnedItem = nil } } .scrollDismissesKeyboard(.immediately) - .background(ViewFrameReader(frame: $viewFrame)) - .timelineStyle(settings.timelineStyle) - .onAppear { - timelineItems = context.viewState.items - } .onReceive(scrollToBottomPublisher) { scrollToBottom(animated: true) } - .onChange(of: context.viewState.items) { items in - guard - !context.viewState.items.isEmpty, - context.viewState.items.count != timelineItems.count - else { - // Update the items, but don't worry about scrolling if the count is unchanged. - timelineItems = items - return - } - - // Pin to the bottom if empty - if timelineItems.isEmpty { - if let lastItem = context.viewState.items.last { - let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false) - timelineItems = context.viewState.items - self.pinnedItem = pinnedItem - } - - return - } - - // Pin to the new bottom if visible - if visibleEdges.contains(.bottom), let newLastItem = context.viewState.items.last { - let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false) - timelineItems = context.viewState.items - self.pinnedItem = pinnedItem - - return - } - - // Pin to the old topmost visible - if visibleEdges.contains(.top), let currentFirstItem = timelineItems.first { - let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false) - timelineItems = context.viewState.items - self.pinnedItem = pinnedItem - - return - } - - // Otherwise just update the items - timelineItems = context.viewState.items - } - .onChange(of: viewFrame) { _ in - // Use the cached version as visibleEdges will already have changed - // (but its onChange handler is yet to be called - possible race condition?) - guard cachedVisibleEdges.contains(.bottom) else { return } - - // Pin the timeline to the bottom if was there on the frame change - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - scrollToBottom(animated: true) - } - } } // MARK: - Private - private func scrollToBottom(animated: Bool = false) { - if let lastItem = timelineItems.last { - pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: animated) - } - } - - private func requestBackPagination() { - guard !context.viewState.isBackPaginating else { - return - } - context.send(viewAction: .loadPreviousPage) - } + private func scrollToBottom(animated: Bool = false) { /* removed */ } private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { guard case let .reply(selectedItemId, _) = context.viewState.composerMode else { @@ -191,12 +82,6 @@ struct TimelineItemList: View { } } -private struct PinnedItem: Equatable { - let id: String - let anchor: UnitPoint - let animated: Bool -} - struct TimelineItemList_Previews: PreviewProvider { static var previews: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), @@ -204,7 +89,7 @@ struct TimelineItemList_Previews: PreviewProvider { mediaProvider: MockMediaProvider(), roomName: nil) - TimelineItemList(visibleEdges: .constant([]), scrollToBottomPublisher: PassthroughSubject()) + TimelineItemList(scrollToBottomPublisher: PassthroughSubject()) .environmentObject(viewModel.context) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 280a0420fe..aea2a050a8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -21,27 +21,24 @@ import SwiftUI import Introspect struct TimelineView: View { - @State private var visibleEdges: [VerticalEdge] = [] + @ObservedObject private var settings = ElementSettings.shared + @State private var scrollToBottomPublisher = PassthroughSubject() @State private var scrollToBottomButtonVisible = false var body: some View { - ZStack(alignment: .bottomTrailing) { - TimelineItemList(visibleEdges: $visibleEdges, scrollToBottomPublisher: scrollToBottomPublisher) - scrollToBottomButton - } + TimelineCollectionView(scrollToBottomPublisher: scrollToBottomPublisher, + scrollToBottomButtonVisible: $scrollToBottomButtonVisible) + .timelineStyle(settings.timelineStyle) + .overlay(alignment: .bottomTrailing) { scrollToBottomButton } } - @ViewBuilder private var scrollToBottomButton: some View { Button { scrollToBottomPublisher.send(()) } label: { Image(uiImage: Asset.Images.timelineScrollToBottom.image) .shadow(radius: 2.0) .padding() } - .onChange(of: visibleEdges) { edges in - scrollToBottomButtonVisible = !edges.contains(.bottom) - } .opacity(scrollToBottomButtonVisible ? 1.0 : 0.0) .animation(.elementDefault, value: scrollToBottomButtonVisible) } diff --git a/ElementX/Sources/Services/Media/MediaSourceProxy.swift b/ElementX/Sources/Services/Media/MediaSourceProxy.swift index 2f4c609811..c675873a27 100644 --- a/ElementX/Sources/Services/Media/MediaSourceProxy.swift +++ b/ElementX/Sources/Services/Media/MediaSourceProxy.swift @@ -17,7 +17,7 @@ import Foundation import MatrixRustSDK -struct MediaSourceProxy: Equatable { +struct MediaSourceProxy: Hashable { let underlyingSource: MediaSource init(source: MediaSource) { @@ -32,9 +32,16 @@ struct MediaSourceProxy: Equatable { underlyingSource.url() } - // MARK: - Equatable +} + +// MARK: - Hashable + +extension MediaSource: Hashable { + public static func == (lhs: MediaSource, rhs: MediaSource) -> Bool { + lhs.url() == rhs.url() + } - static func == (lhs: MediaSourceProxy, rhs: MediaSourceProxy) -> Bool { - lhs.url == rhs.url + public func hash(into hasher: inout Hasher) { + hasher.combine(url()) } } diff --git a/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift b/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift index 1cb39ebd75..b9767221e6 100644 --- a/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift +++ b/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift @@ -17,7 +17,7 @@ import Foundation /// Represents all reactions of the same type for a single event. -struct AggregatedReaction: Equatable, Hashable { +struct AggregatedReaction: Hashable { /// The reaction that was sent. let key: String /// The number of times this reactions was sent. diff --git a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift index 1f83850455..032dae2731 100644 --- a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift @@ -24,7 +24,7 @@ protocol MessageContentProtocol: RoomMessageEventContentProtocol { } /// The delivery status for the item. -enum MessageTimelineItemDeliveryStatus: Equatable { +enum MessageTimelineItemDeliveryStatus: Hashable { case unknown case sending case sent(elapsedTime: TimeInterval) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 5c4d1931e3..8fb375dc19 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -enum TimelineItemInGroupState { +enum TimelineItemInGroupState: Hashable { case single case beginning case middle diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift index f3aaf56d23..23a7ede376 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String var attributedComponents: [AttributedStringBuilderComponent]? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift index 0853a31e1b..0e9cc5853a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift @@ -16,8 +16,8 @@ import UIKit -struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { - enum EncryptionType: Equatable { +struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { + enum EncryptionType: Hashable { case megolmV1AesSha2(sessionId: String) case olmV1Curve25519AesSha2(senderKey: String) case unknown diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift index 35f9c773e4..278daf8b95 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift index edb5a35a61..2cc790e576 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift index 560c084e79..a94b15f7a5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String var attributedComponents: [AttributedStringBuilderComponent]? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift index 93d1ae5577..7a0516b5dd 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift index f7a5092bd6..33c2372257 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift @@ -17,7 +17,7 @@ import Foundation /// Properties of a matrix event that are common between all timeline items. -struct RoomTimelineItemProperties: Equatable { +struct RoomTimelineItemProperties: Hashable { /// Whether the item has been edited. var isEdited = false /// The aggregated reactions that have been sent for this item. diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift index 7d0567a97e..81458ee297 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift @@ -16,7 +16,7 @@ import Foundation -struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Equatable { +struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift index 28895958de..3acff832dc 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String var attributedComponents: [AttributedStringBuilderComponent]? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift index 4577e768d1..f9c0d7c063 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift index 5a3aa349db..011a23d9d5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift @@ -17,7 +17,7 @@ import Foundation import SwiftUI -enum RoomTimelineViewProvider: Identifiable, Equatable { +enum RoomTimelineViewProvider: Identifiable, Hashable { case text(TextRoomTimelineItem) case separator(SeparatorRoomTimelineItem) case image(ImageRoomTimelineItem) From 36ed25eeafcae466c219713358f8c6b91c18464f Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 25 Nov 2022 11:54:13 +0000 Subject: [PATCH 02/10] Switch to a table view. --- ElementX.xcodeproj/project.pbxproj | 56 +-- .../Sources/Other/ScrollViewAdapter.swift | 15 +- .../Screens/RoomScreen/RoomScreenModels.swift | 2 +- .../RoomScreen/RoomScreenViewModel.swift | 2 +- .../View/TimelineCollectionView.swift | 265 ------------ .../RoomScreen/View/TimelineItemList.swift | 95 ----- .../RoomScreen/View/TimelineScrollView.swift | 76 ---- .../RoomScreen/View/TimelineTableView.swift | 383 ++++++++++++++++++ .../RoomScreen/View/TimelineView.swift | 4 +- 9 files changed, 402 insertions(+), 496 deletions(-) delete mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift delete mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift delete mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index c7f9e4b2e5..074e79ff7c 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ @@ -98,7 +98,6 @@ 323F36D880363C473B81A9EA /* MediaProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3D31C2DA6910AA0079678A /* MediaProxyProtocol.swift */; }; 3274219F7F26A5C6C2C55630 /* FilePreviewViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */; }; 32BA37B01B05261FCF2D4B45 /* WeakDictionaryKeyReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090CA61A835C151CEDF8F372 /* WeakDictionaryKeyReference.swift */; }; - 33665CE55037D029ED7D867E /* TimelineScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A627CE1429374617334FA5E9 /* TimelineScrollView.swift */; }; 33CAC1226DFB8B5D8447D286 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; 33D630461FC4562CC767EE9F /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B0B1226DA8DB55918B34CD /* FileCache.swift */; }; 34966D4C1C2C6D37FE3F7F50 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */; }; @@ -114,6 +113,7 @@ 38C76D586404C1FDED095F3A /* LoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B01468022EC826CB2FD2C0 /* LoginModels.swift */; }; 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.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 */; }; @@ -139,7 +139,6 @@ 49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; }; 4A2E0DBB63919AC8309B6D40 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */; }; 4C3365818DE1CEAEDF590FD3 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; }; - 4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */; }; 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 */; }; @@ -255,7 +254,6 @@ 8F2FAA98457750D9D664136F /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 7731767AE437BA3BD2CC14A8 /* Sentry */; }; 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; - 9158AAEC292F83CF00941CAE /* TimelineCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9158AAEB292F83CF00941CAE /* TimelineCollectionView.swift */; }; 91DFCB641FBA03EE2DA0189E /* FilePreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB27E1BE894F9F9F0134372 /* FilePreviewScreen.swift */; }; 9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; }; 93875ADD456142D20823ED24 /* ServerSelectionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */; }; @@ -528,6 +526,7 @@ 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 = ""; }; @@ -716,7 +715,6 @@ 7E154FEA1E6FE964D3DF7859 /* fy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fy; path = fy.lproj/Localizable.strings; sourceTree = ""; }; 7E532D95330139D118A9BF88 /* BugReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModel.swift; sourceTree = ""; }; 7FB27E1BE894F9F9F0134372 /* FilePreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewScreen.swift; sourceTree = ""; }; - 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = ""; }; 8140010A796DB2C7977B6643 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 8166F121C79C7B62BF01D508 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = pt; path = pt.lproj/Localizable.stringsdict; sourceTree = ""; }; 81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -734,18 +732,17 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; - 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; + 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = ""; }; 9080CDD3881D0D1B2F280A7C /* MockUserNotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserNotificationController.swift; sourceTree = ""; }; - 9158AAEB292F83CF00941CAE /* TimelineCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineCollectionView.swift; sourceTree = ""; }; 9238D3A3A00F45E841FE4EFF /* DebugScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugScreen.swift; sourceTree = ""; }; 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = ""; }; 9349F590E35CE514A71E6764 /* LoginHomeserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginHomeserver.swift; sourceTree = ""; }; @@ -790,7 +787,6 @@ A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = ""; }; A4B5B19A10D3F7C2BC5315DF /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = ""; }; A5B0B1226DA8DB55918B34CD /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; - A627CE1429374617334FA5E9 /* TimelineScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollView.swift; sourceTree = ""; }; A64F0DB78E0AC23C91AD89EF /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = mk.lproj/Localizable.strings; sourceTree = ""; }; A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; @@ -931,7 +927,7 @@ EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1630,10 +1626,8 @@ 422724361B6555364C43281E /* RoomHeaderView.swift */, 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */, B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */, - 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */, - 9158AAEB292F83CF00941CAE /* TimelineCollectionView.swift */, - A627CE1429374617334FA5E9 /* TimelineScrollView.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, + 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */, 874A1842477895F199567BD7 /* TimelineView.swift */, A312471EA62EFB0FD94E60DC /* Style */, CCD48459CA34A1928EC7A26A /* Supplementary */, @@ -1916,14 +1910,6 @@ path = NSE; sourceTree = ""; }; - B3E78735F63FA93FAAAF700A /* MockUserNotificationController.swift~refs */ = { - isa = PBXGroup; - children = ( - F798CDE87F83A94B8BC2E18A /* remotes */, - ); - path = "MockUserNotificationController.swift~refs"; - sourceTree = ""; - }; B442FCF47E0A6F28D7D50A4D /* FilePreview */ = { isa = PBXGroup; children = ( @@ -2011,13 +1997,6 @@ path = UITests; sourceTree = ""; }; - C5A8A8B1C16BBFEA4B9D5988 /* origin */ = { - isa = PBXGroup; - children = ( - ); - path = origin; - sourceTree = ""; - }; CA555F7C7CA382ACACF0D82B /* Keychain */ = { isa = PBXGroup; children = ( @@ -2075,13 +2054,6 @@ path = Vendor; sourceTree = ""; }; - D14F980E72A97D6169A499E8 /* ImageViewer */ = { - isa = PBXGroup; - children = ( - ); - path = ImageViewer; - sourceTree = ""; - }; D958761758AA1110476DE6A3 /* SessionVerification */ = { isa = PBXGroup; children = ( @@ -2105,7 +2077,6 @@ CD80F22830C2360F3F39DDCE /* UserNotificationModalView.swift */, 649759084B0C9FE1F8DF8D17 /* UserNotificationPresenter.swift */, F31A4E5941ACBA4BB9FEF94C /* UserNotificationToastView.swift */, - B3E78735F63FA93FAAAF700A /* MockUserNotificationController.swift~refs */, ); path = UserNotifications; sourceTree = ""; @@ -2146,7 +2117,6 @@ 4009BE2E791C16AC6EE39A7E /* BugReport */, B442FCF47E0A6F28D7D50A4D /* FilePreview */, B53CA9BECD3F97805E1432D0 /* HomeScreen */, - D14F980E72A97D6169A499E8 /* ImageViewer */, 3F38EAC92E2281990E65DAF2 /* OnboardingScreen */, A448A3A8F764174C60CD0CA1 /* Other */, 679E9837ECA8D6776079D16E /* RoomScreen */, @@ -2204,14 +2174,6 @@ path = Background; sourceTree = ""; }; - F798CDE87F83A94B8BC2E18A /* remotes */ = { - isa = PBXGroup; - children = ( - C5A8A8B1C16BBFEA4B9D5988 /* origin */, - ); - path = remotes; - sourceTree = ""; - }; FCDF06BDB123505F0334B4F9 /* Timeline */ = { isa = PBXGroup; children = ( @@ -2717,7 +2679,6 @@ files = ( 098CE03C6CC71A31F263FA33 /* ActivityCoordinator.swift in Sources */, 7096FA3AC218D914E88BFB70 /* AggregratedReaction.swift in Sources */, - 9158AAEC292F83CF00941CAE /* TimelineCollectionView.swift in Sources */, A50849766F056FD1DB942DEA /* AlertInfo.swift in Sources */, 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */, A371629728E597C5FCA3C2B2 /* Analytics.swift in Sources */, @@ -2937,14 +2898,13 @@ 5D2AF8C0DF872E7985F8FE54 /* TimelineDeliveryStatusView.swift in Sources */, 157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */, 01CB8ACFA5E143E89C168CA8 /* TimelineItemContextMenu.swift in Sources */, - 4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */, F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */, 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */, 9B582B3EEFEA615D4A6FBF1A /* TimelineReactionsView.swift in Sources */, - 33665CE55037D029ED7D867E /* TimelineScrollView.swift in Sources */, ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */, 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */, FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */, + 3B0DF16FEA8481061C28A3BE /* TimelineTableView.swift in Sources */, 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */, 7732B2F635626BE1C1CD92A4 /* UIActivityViewControllerWrapper.swift in Sources */, 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */, diff --git a/ElementX/Sources/Other/ScrollViewAdapter.swift b/ElementX/Sources/Other/ScrollViewAdapter.swift index 4df8a2cf15..996023d3a1 100644 --- a/ElementX/Sources/Other/ScrollViewAdapter.swift +++ b/ElementX/Sources/Other/ScrollViewAdapter.swift @@ -25,30 +25,29 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate { } } - var isScrolling = PassthroughSubject() + var isScrolling = CurrentValueSubject(false) - private func update() { - guard let scrollView else { return } + private func update(_ scrollView: UIScrollView) { isScrolling.send(scrollView.isDragging || scrollView.isDecelerating) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - update() + update(scrollView) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - update() + update(scrollView) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - update() + update(scrollView) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - update() + update(scrollView) } func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { - update() + update(scrollView) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 3c9b7e0518..02811b9903 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -57,7 +57,7 @@ struct RoomScreenViewState: BindableState { bindings.composerText.count == 0 } - let loadPreviousPagePublisher = PassthroughSubject() + let paginateBackwardsPublisher = PassthroughSubject() } struct RoomScreenViewStateBindings { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index d86b484e48..6bfcd64b64 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -73,7 +73,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.contextMenuBuilder = buildContexMenuForItemId(_:) - state.loadPreviousPagePublisher + state.paginateBackwardsPublisher .collect(.byTime(DispatchQueue.main, 0.1)) .sink { [weak self] _ in Task { await self?.paginateBackwards() } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift deleted file mode 100644 index edccc1e7c7..0000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineCollectionView.swift +++ /dev/null @@ -1,265 +0,0 @@ -// -// TimelineListView.swift -// UITimeline -// -// Created by Doug on 23/11/2022. -// - -import Combine -import SwiftUI - -class TimelineItemSwiftUICell: UICollectionViewCell { - #warning("Do more in here") - var timelineItem: RoomTimelineViewProvider? -} - -struct TimelineCollectionView: UIViewRepresentable { - @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context - @Environment(\.timelineStyle) private var timelineStyle - - #warning("Should this come via the context like loadPreviousPage???") - let scrollToBottomPublisher: PassthroughSubject - @Binding var scrollToBottomButtonVisible: Bool - - func makeUIView(context: Context) -> UICollectionView { - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: context.coordinator.makeLayout()) - collectionView.keyboardDismissMode = .onDrag - context.coordinator.collectionView = collectionView - context.coordinator.loadPreviousPagePublisher = viewModelContext.viewState.loadPreviousPagePublisher - return collectionView - } - - func updateUIView(_ uiView: UICollectionView, context: Context) { - if context.coordinator.timelineItems != viewModelContext.viewState.items { - context.coordinator.timelineItems = viewModelContext.viewState.items - } - if context.coordinator.isBackPaginating != viewModelContext.viewState.isBackPaginating { - context.coordinator.isBackPaginating = viewModelContext.viewState.isBackPaginating - } - if context.coordinator.timelineStyle != timelineStyle { - context.coordinator.timelineStyle = timelineStyle - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(viewModelContext: viewModelContext, - scrollToBottomPublisher: scrollToBottomPublisher, - scrollToBottomButtonVisible: $scrollToBottomButtonVisible) - } - - // MARK: - Coordinator - - @MainActor - class Coordinator: NSObject { - let viewModelContext: RoomScreenViewModel.Context - - var collectionView: UICollectionView? { - didSet { - configureDataSource() - } - } - - var timelineStyle: TimelineStyle = .bubbles - var timelineItems: [RoomTimelineViewProvider] = [] { - didSet { - applySnapshot() - } - } - - var isBackPaginating = false { - didSet { - // TODO: Paginate again if needed - } - } - - var loadPreviousPagePublisher = PassthroughSubject() - var cancellables: Set = [] - @Binding var scrollToBottomButtonVisible: Bool - - private var dataSource: UICollectionViewDiffableDataSource? - - init(viewModelContext: RoomScreenViewModel.Context, - scrollToBottomPublisher: PassthroughSubject, - scrollToBottomButtonVisible: Binding) { - self.viewModelContext = viewModelContext - _scrollToBottomButtonVisible = scrollToBottomButtonVisible - super.init() - - scrollToBottomPublisher - .sink { [weak self] _ in - self?.scrollToBottom(animated: true) - } - .store(in: &cancellables) - } - - func makeLayout() -> UICollectionViewCompositionalLayout { - var configuration = UICollectionLayoutListConfiguration(appearance: .plain) - configuration.showsSeparators = false - return UICollectionViewCompositionalLayout.list(using: configuration) - } - - private func configureDataSource() { - guard let collectionView else { return } - let cellRegistration = UICollectionView.CellRegistration { cell, indexPath, timelineItem in - cell.timelineItem = timelineItem - } - - dataSource = .init(collectionView: collectionView) { collectionView, indexPath, timelineItem in - #warning("Do we need a weak self here???") - let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: timelineItem) - cell.contentConfiguration = UIHostingConfiguration { - timelineItem - .frame(maxWidth: .infinity, alignment: .leading) - .contextMenu { - self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) - } - .opacity(self.opacityForItem(timelineItem)) - .onAppear { - self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) - } - .onDisappear { - self.viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) - } - .environment(\.openURL, OpenURLAction { url in - self.viewModelContext.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - .onTapGesture { - self.viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) - } - } - .margins(.all, self.timelineStyle.rowInsets) - - return cell - } - - collectionView.delegate = self - } - - func applySnapshot() { - let previousLayout = layout() - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(timelineItems) - dataSource?.apply(snapshot, animatingDifferences: false) - - guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } - - if previousLayout.isBottomVisible || previousLayout.isEmpty { - let animated = !previousLayout.isEmpty - scrollToBottom(animated: false) - } else if let pinnedItem = previousLayout.pinnedItem, - let collectionView, - let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }), - let indexPath = dataSource?.indexPath(for: item) { - collectionView.scrollToItem(at: indexPath, at: pinnedItem.position, animated: false) - } - } - - func layout() -> LayoutDescriptor { - guard let collectionView, let dataSource else { return LayoutDescriptor() } - - let snapshot = dataSource.snapshot() - var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems, contentSize: collectionView.contentSize) - - guard !snapshot.itemIdentifiers.isEmpty else { - layout.isEmpty = true - return layout - } - - if let firstVisibleIndexPath = collectionView.indexPathsForVisibleItems.first, - let firstVisibleItem = dataSource.itemIdentifier(for: firstVisibleIndexPath) { - layout.pinnedItem = PinnedItem(id: firstVisibleItem.id, position: .top) - } - - if let lastItem = snapshot.itemIdentifiers.last, - let lastIndexPath = dataSource.indexPath(for: lastItem) { - layout.isBottomVisible = collectionView.indexPathsForVisibleItems.contains(lastIndexPath) - } - - return layout - } - - func scrollToBottom(animated: Bool) { - guard let lastItem = timelineItems.last, - let lastIndexPath = dataSource?.indexPath(for: lastItem) - else { return } - - collectionView?.scrollToItem(at: lastIndexPath, at: .bottom, animated: animated) - } - - private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { - guard case let .reply(selectedItemId, _) = viewModelContext.viewState.composerMode else { - return 1.0 - } - - return selectedItemId == item.id ? 1.0 : 0.5 - } - - // TODO: Handle frame changes. - } - - enum TimelineSection { case main } - - struct LayoutDescriptor { - var numberOfItems = 0 - var pinnedItem: PinnedItem? - var isBottomVisible = false - var isEmpty = false - var contentSize: CGSize = .zero - } - - struct PinnedItem { - let id: String - let position: UICollectionView.ScrollPosition - } -} - -// MARK: - UICollectionViewDelegate - -extension TimelineCollectionView.Coordinator: UICollectionViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let isAtBottom = isAtBottom(of: scrollView) - - if !scrollToBottomButtonVisible, isAtBottom { - DispatchQueue.main.async { self.scrollToBottomButtonVisible = true } - } else if scrollToBottomButtonVisible, !isAtBottom { - DispatchQueue.main.async { self.scrollToBottomButtonVisible = false } - } - - if scrollView.contentOffset.y < paginationThreshold(for: scrollView), !isBackPaginating { - loadPreviousPagePublisher.send(()) - } - } - - func isAtBottom(of scrollView: UIScrollView) -> Bool { - scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.visibleSize.height - 15) - } - - func paginationThreshold(for scrollView: UIScrollView) -> Double { - scrollView.visibleSize.height * 2.0 - } -} - - -// MARK: - Previews - -struct TimelineCollectionView_Previews: PreviewProvider { - static var previews: some View { - body.preferredColorScheme(.light) - body.preferredColorScheme(.dark) - } - - @ViewBuilder - static var body: 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/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift deleted file mode 100644 index ef12c65187..0000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ /dev/null @@ -1,95 +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 - -struct TimelineItemList: View { - @State private var timelineItems: [RoomTimelineViewProvider] = [] - - @EnvironmentObject var context: RoomScreenViewModel.Context - - let scrollToBottomPublisher: PassthroughSubject - - var body: some View { - TimelineScrollView(visibleEdges: .constant([])) { - // The scroll view already contains a VStack so simply provide the content to fill it. - - ProgressView() - .frame(maxWidth: .infinity) - .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) - .animation(.elementDefault, value: context.viewState.isBackPaginating) - - ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in - item - .contextMenu { - context.viewState.contextMenuBuilder?(item.id) - .id(item.id) - } - .opacity(opacityForItem(item)) - .onAppear { - context.send(viewAction: .itemAppeared(id: item.id)) - } - .onDisappear { - context.send(viewAction: .itemDisappeared(id: item.id)) - } - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - .onTapGesture { - context.send(viewAction: .itemTapped(id: item.id)) - } - } - } - .scrollDismissesKeyboard(.immediately) - .onReceive(scrollToBottomPublisher) { - scrollToBottom(animated: true) - } - } - - // MARK: - Private - - private func scrollToBottom(animated: Bool = false) { /* removed */ } - - private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { - guard case let .reply(selectedItemId, _) = context.viewState.composerMode else { - return 1.0 - } - - return selectedItemId == item.id ? 1.0 : 0.5 - } - - private var isRunningPreviews: Bool { - #if DEBUG - return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - #else - return false - #endif - } -} - -struct TimelineItemList_Previews: PreviewProvider { - static var previews: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), - mediaProvider: MockMediaProvider(), - roomName: nil) - - TimelineItemList(scrollToBottomPublisher: PassthroughSubject()) - .environmentObject(viewModel.context) - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift deleted file mode 100644 index 29f4414762..0000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift +++ /dev/null @@ -1,76 +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 SwiftUI - -struct VisibleEdgesKey: PreferenceKey { - static var defaultValue: [VerticalEdge] = [] - - static func reduce(value: inout [VerticalEdge], nextValue: () -> [VerticalEdge]) { - value = nextValue() - } -} - -/// A SwiftUI scroll view with the following customisations for a room timeline -/// - The content is laid out starting at the bottom. -/// - Top and bottom edge visibility detection for triggering other behaviours. -struct TimelineScrollView: View { - @Binding var visibleEdges: [VerticalEdge] - - @ViewBuilder var content: () -> Content - - /// A small threshold added to the edge detection to allow a bit of leniency. - private let edgeDetectionThreshold: CGFloat = 15 - - var body: some View { - GeometryReader { scrollViewGeometry in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Spacer() - content() - } - .frame(minHeight: scrollViewGeometry.size.height) - .background { - GeometryReader { contentGeometry in - Color.clear - .preference(key: VisibleEdgesKey.self, - value: visibleEdges(of: contentGeometry, in: scrollViewGeometry)) - } - .onPreferenceChange(VisibleEdgesKey.self) { - visibleEdges = $0 - } - } - } - } - } - - func visibleEdges(of contentGeometry: GeometryProxy, in scrollViewGeometry: GeometryProxy) -> [VerticalEdge] { - let frame = contentGeometry.frame(in: .global) - let isTopVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.minY + edgeDetectionThreshold)) - let isBottomVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.maxY - edgeDetectionThreshold)) - - switch (isTopVisible, isBottomVisible) { - case (false, false): - return [] - case (true, false): - return [.top] - case (false, true): - return [.bottom] - case (true, true): - return [.top, .bottom] - } - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift new file mode 100644 index 0000000000..297baf49b1 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -0,0 +1,383 @@ +// +// 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 + +class TimelineItemCell: UITableViewCell { + var item: RoomTimelineViewProvider? + + override func prepareForReuse() { + item = nil + } +} + +struct TimelineTableView: UIViewRepresentable { + enum Constants { + static let cellIdentifier = "TimelineCell" + } + + @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context + @Environment(\.timelineStyle) private var timelineStyle + + #warning("Should this come via the context like loadPreviousPage???") + let scrollToBottomPublisher: PassthroughSubject + @Binding var scrollToBottomButtonVisible: Bool + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.register(TimelineItemCell.self, forCellReuseIdentifier: Constants.cellIdentifier) + tableView.separatorStyle = .none + tableView.allowsSelection = false + tableView.keyboardDismissMode = .onDrag + context.coordinator.tableView = tableView + context.coordinator.paginateBackwardsPublisher = viewModelContext.viewState.paginateBackwardsPublisher + context.coordinator.paginateBackwardsPublisher.send(()) + return tableView + } + + func updateUIView(_ uiView: UITableView, context: Context) { + if context.coordinator.timelineItems != viewModelContext.viewState.items { + context.coordinator.timelineItems = viewModelContext.viewState.items + } + if context.coordinator.isBackPaginating != viewModelContext.viewState.isBackPaginating { + context.coordinator.isBackPaginating = viewModelContext.viewState.isBackPaginating + } + if context.coordinator.timelineStyle != timelineStyle { + context.coordinator.timelineStyle = timelineStyle + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModelContext: viewModelContext, + scrollToBottomPublisher: scrollToBottomPublisher, + scrollToBottomButtonVisible: $scrollToBottomButtonVisible) + } + + // 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 !adapter.isScrolling.value else { + hasPendingUpdates = true + return + } + + applySnapshot() + } + } + + var isBackPaginating = false { + didSet { + paginateBackwardsIfNeeded() + } + } + + var paginateBackwardsPublisher = PassthroughSubject() + @Binding var scrollToBottomButtonVisible: Bool + + private var dataSource: UITableViewDiffableDataSource? + private var cancellables: Set = [] + private let adapter = ScrollViewAdapter() + private var hasPendingUpdates = false + private var frameObserverToken: NSKeyValueObservation? + + init(viewModelContext: RoomScreenViewModel.Context, + scrollToBottomPublisher: PassthroughSubject, + scrollToBottomButtonVisible: Binding) { + self.viewModelContext = viewModelContext + _scrollToBottomButtonVisible = scrollToBottomButtonVisible + super.init() + + scrollToBottomPublisher + .sink { [weak self] _ in + self?.scrollToBottom(animated: true) + } + .store(in: &cancellables) + + adapter.isScrolling + .sink { [weak self] isScrolling in + guard !isScrolling, let self, self.hasPendingUpdates else { return } + self.applySnapshot() + self.hasPendingUpdates = false + self.paginateBackwardsIfNeeded() + } + .store(in: &cancellables) + } + + private func configureDataSource() { + guard let tableView else { return } + + dataSource = .init(tableView: tableView) { tableView, indexPath, timelineItem in + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellIdentifier, for: indexPath) + guard let cell = cell as? TimelineItemCell else { return cell } + + cell.item = timelineItem + #warning("Do we need a weak self here???") + cell.contentConfiguration = UIHostingConfiguration { + timelineItem + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) + } + .opacity(self.opacityForItem(timelineItem)) + .onAppear { + self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) + } + .onDisappear { + self.viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) + } + .environment(\.openURL, OpenURLAction { url in + self.viewModelContext.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) + .onTapGesture { + self.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 + guard let self else { return } + let previousLayout = self.layout() + + if previousLayout.isBottomVisible { + self.scrollToBottom(animated: false) + } + } + } + + 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) + + updateHeader() + + guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } + + if previousLayout.isBottomVisible { + scrollToBottom(animated: false) + } else if let pinnedItem = previousLayout.pinnedItem { + pinItem(pinnedItem, using: snapshot) + } + } + + 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 + } + + if let pinnedIndexPath = tableView.indexPathsForVisibleRows?.last, + let pinnedItem = dataSource.itemIdentifier(for: pinnedIndexPath) { + let pinnedCellFrame = tableView.cellFrame(for: pinnedItem) + layout.pinnedItem = PinnedItem(id: pinnedItem.id, position: .bottom, frame: pinnedCellFrame) + layout.isBottomVisible = pinnedItem == snapshot.itemIdentifiers.last + } + + return layout + } + + private func updateHeader() { + 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) + } else { + tableView.tableHeaderView = nil + } + } + + private func isAtBottom(of scrollView: UIScrollView) -> Bool { + scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.visibleSize.height - 15) + } + + 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) + } + + private func pinItem(_ pinnedItem: PinnedItem, using 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 + } + } + + private func paginateBackwardsIfNeeded() { + guard let tableView, + !isBackPaginating, + !hasPendingUpdates, + tableView.contentOffset.y < tableView.visibleSize.height * 2.0 + else { return } + + paginateBackwardsPublisher.send(()) + } + + private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { + guard case let .reply(selectedItemId, _) = viewModelContext.viewState.composerMode else { + return 1.0 + } + + return selectedItemId == item.id ? 1.0 : 0.5 + } + + // TODO: Handle frame changes. + } +} + +// MARK: - UITableViewDelegate + +extension TimelineTableView.Coordinator: UITableViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let isAtBottom = isAtBottom(of: scrollView) + + if !scrollToBottomButtonVisible, isAtBottom { + DispatchQueue.main.async { self.scrollToBottomButtonVisible = true } + } else if scrollToBottomButtonVisible, !isAtBottom { + DispatchQueue.main.async { self.scrollToBottomButtonVisible = false } + } + + paginateBackwardsIfNeeded() + } + + // MARK: - ScrollViewAdapter + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + adapter.scrollViewWillBeginDragging(scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + adapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + adapter.scrollViewDidEndScrollingAnimation(scrollView) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + adapter.scrollViewDidEndDecelerating(scrollView) + } + + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + adapter.scrollViewDidScrollToTop(scrollView) + } +} + +// MARK: - Layout Types + +extension TimelineTableView.Coordinator { + enum TimelineSection { case main } + + struct LayoutDescriptor { + var numberOfItems = 0 + var pinnedItem: PinnedItem? + var isBottomVisible = false + } + + struct PinnedItem { + let id: String + let position: UITableView.ScrollPosition + let frame: CGRect? + } +} + +// MARK: - Cell Layout + +private extension UITableView { + 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 { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: 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/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index aea2a050a8..f8147bf7e2 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -27,8 +27,8 @@ struct TimelineView: View { @State private var scrollToBottomButtonVisible = false var body: some View { - TimelineCollectionView(scrollToBottomPublisher: scrollToBottomPublisher, - scrollToBottomButtonVisible: $scrollToBottomButtonVisible) + TimelineTableView(scrollToBottomPublisher: scrollToBottomPublisher, + scrollToBottomButtonVisible: $scrollToBottomButtonVisible) .timelineStyle(settings.timelineStyle) .overlay(alignment: .bottomTrailing) { scrollToBottomButton } } From 6dbeb4259f41483e1bcfca88b7f918afdd7e5e1e Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 1 Dec 2022 12:34:08 +0000 Subject: [PATCH 03/10] Tidy up. --- .../Screens/RoomScreen/RoomScreenModels.swift | 4 + .../RoomScreen/RoomScreenViewModel.swift | 6 ++ .../RoomScreen/View/TimelineTableView.swift | 96 +++++++++++-------- .../RoomScreen/View/TimelineView.swift | 13 +-- 4 files changed, 73 insertions(+), 46 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 02811b9903..5083eec8a7 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -57,13 +57,17 @@ struct RoomScreenViewState: BindableState { bindings.composerText.count == 0 } + let viewActionPublisher = PassthroughSubject() let paginateBackwardsPublisher = PassthroughSubject() + let scrollToBottomPublisher = PassthroughSubject() } struct RoomScreenViewStateBindings { var composerText: String var composerFocused: Bool + var scrollToBottomButtonVisible = false + /// Information describing the currently displayed alert. var alertInfo: AlertInfo? diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 6bfcd64b64..cf9630cb7e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -80,6 +80,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } .store(in: &cancellables) + state.viewActionPublisher + .sink { [weak self] action in + self?.context.send(viewAction: action) + } + .store(in: &cancellables) + buildTimelineViews() if let roomAvatarUrl { diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index 297baf49b1..f0d42c152e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -18,6 +18,8 @@ import Combine import SwiftUI class TimelineItemCell: UITableViewCell { + static let reuseIdentifier = "TimelineCell" + var item: RoomTimelineViewProvider? override func prepareForReuse() { @@ -26,25 +28,16 @@ class TimelineItemCell: UITableViewCell { } struct TimelineTableView: UIViewRepresentable { - enum Constants { - static let cellIdentifier = "TimelineCell" - } - @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context @Environment(\.timelineStyle) private var timelineStyle - #warning("Should this come via the context like loadPreviousPage???") - let scrollToBottomPublisher: PassthroughSubject - @Binding var scrollToBottomButtonVisible: Bool - func makeUIView(context: Context) -> UITableView { let tableView = UITableView(frame: .zero, style: .plain) - tableView.register(TimelineItemCell.self, forCellReuseIdentifier: Constants.cellIdentifier) + tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier) tableView.separatorStyle = .none tableView.allowsSelection = false tableView.keyboardDismissMode = .onDrag context.coordinator.tableView = tableView - context.coordinator.paginateBackwardsPublisher = viewModelContext.viewState.paginateBackwardsPublisher context.coordinator.paginateBackwardsPublisher.send(()) return tableView } @@ -59,20 +52,24 @@ struct TimelineTableView: UIViewRepresentable { if context.coordinator.timelineStyle != timelineStyle { context.coordinator.timelineStyle = timelineStyle } + if case let .reply(selectedItemID, _) = viewModelContext.viewState.composerMode { + if context.coordinator.selectedItemID != selectedItemID { + context.coordinator.selectedItemID = selectedItemID + } + } else if context.coordinator.selectedItemID != nil { + context.coordinator.selectedItemID = nil + } } func makeCoordinator() -> Coordinator { Coordinator(viewModelContext: viewModelContext, - scrollToBottomPublisher: scrollToBottomPublisher, - scrollToBottomButtonVisible: $scrollToBottomButtonVisible) + scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible) } // MARK: - Coordinator @MainActor class Coordinator: NSObject { - let viewModelContext: RoomScreenViewModel.Context - var tableView: UITableView? { didSet { registerFrameObserver() @@ -92,13 +89,26 @@ struct TimelineTableView: UIViewRepresentable { } } + var selectedItemID: String? { + 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. + 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) + } + } + var isBackPaginating = false { didSet { paginateBackwardsIfNeeded() } } - var paginateBackwardsPublisher = PassthroughSubject() + private let contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)? + private let viewActionPublisher: PassthroughSubject + let paginateBackwardsPublisher: PassthroughSubject @Binding var scrollToBottomButtonVisible: Bool private var dataSource: UITableViewDiffableDataSource? @@ -108,13 +118,15 @@ struct TimelineTableView: UIViewRepresentable { private var frameObserverToken: NSKeyValueObservation? init(viewModelContext: RoomScreenViewModel.Context, - scrollToBottomPublisher: PassthroughSubject, scrollToBottomButtonVisible: Binding) { - self.viewModelContext = viewModelContext + contextMenuBuilder = viewModelContext.viewState.contextMenuBuilder + viewActionPublisher = viewModelContext.viewState.viewActionPublisher + paginateBackwardsPublisher = viewModelContext.viewState.paginateBackwardsPublisher _scrollToBottomButtonVisible = scrollToBottomButtonVisible + super.init() - scrollToBottomPublisher + viewModelContext.viewState.scrollToBottomPublisher .sink { [weak self] _ in self?.scrollToBottom(animated: true) } @@ -130,11 +142,12 @@ struct TimelineTableView: UIViewRepresentable { .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) { tableView, indexPath, timelineItem in - let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellIdentifier, for: indexPath) + let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath) guard let cell = cell as? TimelineItemCell else { return cell } cell.item = timelineItem @@ -143,21 +156,21 @@ struct TimelineTableView: UIViewRepresentable { timelineItem .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { - self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) + self.contextMenuBuilder?(timelineItem.id) } .opacity(self.opacityForItem(timelineItem)) .onAppear { - self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) + self.viewActionPublisher.send(.itemAppeared(id: timelineItem.id)) } .onDisappear { - self.viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) + self.viewActionPublisher.send(.itemDisappeared(id: timelineItem.id)) } .environment(\.openURL, OpenURLAction { url in - self.viewModelContext.send(viewAction: .linkClicked(url: url)) + self.viewActionPublisher.send(.linkClicked(url: url)) return .systemAction }) .onTapGesture { - self.viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) + self.viewActionPublisher.send(.itemTapped(id: timelineItem.id)) } } .margins(.all, self.timelineStyle.rowInsets) @@ -176,7 +189,7 @@ struct TimelineTableView: UIViewRepresentable { frameObserverToken?.invalidate() frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in - guard let self else { return } + guard let self, self.selectedItemID == nil else { return } let previousLayout = self.layout() if previousLayout.isBottomVisible { @@ -185,6 +198,9 @@ struct TimelineTableView: UIViewRepresentable { } } + /// 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 } @@ -195,17 +211,19 @@ struct TimelineTableView: UIViewRepresentable { snapshot.appendItems(timelineItems) dataSource.apply(snapshot, animatingDifferences: false) - updateHeader() + updateTopPadding() guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } if previousLayout.isBottomVisible { scrollToBottom(animated: false) } else if let pinnedItem = previousLayout.pinnedItem { - pinItem(pinnedItem, using: snapshot) + 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 tableView, let dataSource else { return LayoutDescriptor() } @@ -227,7 +245,9 @@ struct TimelineTableView: UIViewRepresentable { return layout } - private func updateHeader() { + /// 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) @@ -235,16 +255,18 @@ struct TimelineTableView: UIViewRepresentable { if height > 0 { let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height)) - tableView.tableHeaderView = UIView(frame: frame) + 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) @@ -253,7 +275,8 @@ struct TimelineTableView: UIViewRepresentable { tableView?.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated) } - private func pinItem(_ pinnedItem: PinnedItem, using snapshot: NSDiffableDataSourceSnapshot) { + /// 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) @@ -270,7 +293,8 @@ struct TimelineTableView: UIViewRepresentable { tableView.contentOffset.y += deltaY } } - + + /// Checks whether or a backwards pagination is needed and requests one if so. private func paginateBackwardsIfNeeded() { guard let tableView, !isBackPaginating, @@ -281,15 +305,11 @@ struct TimelineTableView: UIViewRepresentable { paginateBackwardsPublisher.send(()) } + /// private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { - guard case let .reply(selectedItemId, _) = viewModelContext.viewState.composerMode else { - return 1.0 - } - - return selectedItemId == item.id ? 1.0 : 0.5 + guard let selectedItemID else { return 1.0 } + return item.id == selectedItemID ? 1.0 : 0.5 } - - // TODO: Handle frame changes. } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index f8147bf7e2..5619a9724d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -22,25 +22,22 @@ import Introspect struct TimelineView: View { @ObservedObject private var settings = ElementSettings.shared - - @State private var scrollToBottomPublisher = PassthroughSubject() - @State private var scrollToBottomButtonVisible = false + @EnvironmentObject private var context: RoomScreenViewModel.Context var body: some View { - TimelineTableView(scrollToBottomPublisher: scrollToBottomPublisher, - scrollToBottomButtonVisible: $scrollToBottomButtonVisible) + TimelineTableView() .timelineStyle(settings.timelineStyle) .overlay(alignment: .bottomTrailing) { scrollToBottomButton } } private var scrollToBottomButton: some View { - Button { scrollToBottomPublisher.send(()) } label: { + Button { context.viewState.scrollToBottomPublisher.send(()) } label: { Image(uiImage: Asset.Images.timelineScrollToBottom.image) .shadow(radius: 2.0) .padding() } - .opacity(scrollToBottomButtonVisible ? 1.0 : 0.0) - .animation(.elementDefault, value: scrollToBottomButtonVisible) + .opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0) + .animation(.elementDefault, value: context.scrollToBottomButtonVisible) } } From 657f8deb275e0fc6b36568c0096b28cb60ab9146 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 1 Dec 2022 16:58:42 +0000 Subject: [PATCH 04/10] Update timeline state directly from the view model --- .../Screens/RoomScreen/RoomScreenModels.swift | 6 ++ .../RoomScreen/View/TimelineTableView.swift | 81 ++++++++----------- 2 files changed, 40 insertions(+), 47 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 5083eec8a7..e6c7106854 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -60,6 +60,12 @@ struct RoomScreenViewState: BindableState { let viewActionPublisher = PassthroughSubject() let paginateBackwardsPublisher = PassthroughSubject() 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/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index f0d42c152e..4b7e3b7d91 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -38,38 +38,28 @@ struct TimelineTableView: UIViewRepresentable { tableView.allowsSelection = false tableView.keyboardDismissMode = .onDrag context.coordinator.tableView = tableView - context.coordinator.paginateBackwardsPublisher.send(()) + viewModelContext.viewState.paginateBackwardsPublisher.send(()) return tableView } func updateUIView(_ uiView: UITableView, context: Context) { - if context.coordinator.timelineItems != viewModelContext.viewState.items { - context.coordinator.timelineItems = viewModelContext.viewState.items - } - if context.coordinator.isBackPaginating != viewModelContext.viewState.isBackPaginating { - context.coordinator.isBackPaginating = viewModelContext.viewState.isBackPaginating - } + context.coordinator.update() + if context.coordinator.timelineStyle != timelineStyle { context.coordinator.timelineStyle = timelineStyle } - if case let .reply(selectedItemID, _) = viewModelContext.viewState.composerMode { - if context.coordinator.selectedItemID != selectedItemID { - context.coordinator.selectedItemID = selectedItemID - } - } else if context.coordinator.selectedItemID != nil { - context.coordinator.selectedItemID = nil - } } func makeCoordinator() -> Coordinator { - Coordinator(viewModelContext: viewModelContext, - scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible) + Coordinator(viewModelContext: viewModelContext) } // MARK: - Coordinator @MainActor class Coordinator: NSObject { + let viewModelContext: RoomScreenViewModel.Context + var tableView: UITableView? { didSet { registerFrameObserver() @@ -89,7 +79,7 @@ struct TimelineTableView: UIViewRepresentable { } } - var selectedItemID: String? { + 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. @@ -106,24 +96,14 @@ struct TimelineTableView: UIViewRepresentable { } } - private let contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)? - private let viewActionPublisher: PassthroughSubject - let paginateBackwardsPublisher: PassthroughSubject - @Binding var scrollToBottomButtonVisible: Bool - private var dataSource: UITableViewDiffableDataSource? private var cancellables: Set = [] private let adapter = ScrollViewAdapter() private var hasPendingUpdates = false private var frameObserverToken: NSKeyValueObservation? - init(viewModelContext: RoomScreenViewModel.Context, - scrollToBottomButtonVisible: Binding) { - contextMenuBuilder = viewModelContext.viewState.contextMenuBuilder - viewActionPublisher = viewModelContext.viewState.viewActionPublisher - paginateBackwardsPublisher = viewModelContext.viewState.paginateBackwardsPublisher - _scrollToBottomButtonVisible = scrollToBottomButtonVisible - + init(viewModelContext: RoomScreenViewModel.Context) { + self.viewModelContext = viewModelContext super.init() viewModelContext.viewState.scrollToBottomPublisher @@ -156,21 +136,21 @@ struct TimelineTableView: UIViewRepresentable { timelineItem .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { - self.contextMenuBuilder?(timelineItem.id) + self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) } - .opacity(self.opacityForItem(timelineItem)) + .opacity(self.viewModelContext.viewState.opacity(for: timelineItem)) .onAppear { - self.viewActionPublisher.send(.itemAppeared(id: timelineItem.id)) + self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) } .onDisappear { - self.viewActionPublisher.send(.itemDisappeared(id: timelineItem.id)) + self.viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) } .environment(\.openURL, OpenURLAction { url in - self.viewActionPublisher.send(.linkClicked(url: url)) + self.viewModelContext.send(viewAction: .linkClicked(url: url)) return .systemAction }) .onTapGesture { - self.viewActionPublisher.send(.itemTapped(id: timelineItem.id)) + self.viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) } } .margins(.all, self.timelineStyle.rowInsets) @@ -189,7 +169,7 @@ struct TimelineTableView: UIViewRepresentable { frameObserverToken?.invalidate() frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in - guard let self, self.selectedItemID == nil else { return } + guard let self, self.composerMode == .default else { return } let previousLayout = self.layout() if previousLayout.isBottomVisible { @@ -198,6 +178,19 @@ struct TimelineTableView: UIViewRepresentable { } } + /// 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 + } + } + /// 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. @@ -302,13 +295,7 @@ struct TimelineTableView: UIViewRepresentable { tableView.contentOffset.y < tableView.visibleSize.height * 2.0 else { return } - paginateBackwardsPublisher.send(()) - } - - /// - private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { - guard let selectedItemID else { return 1.0 } - return item.id == selectedItemID ? 1.0 : 0.5 + viewModelContext.viewState.paginateBackwardsPublisher.send(()) } } } @@ -319,10 +306,10 @@ extension TimelineTableView.Coordinator: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let isAtBottom = isAtBottom(of: scrollView) - if !scrollToBottomButtonVisible, isAtBottom { - DispatchQueue.main.async { self.scrollToBottomButtonVisible = true } - } else if scrollToBottomButtonVisible, !isAtBottom { - DispatchQueue.main.async { self.scrollToBottomButtonVisible = false } + if !viewModelContext.scrollToBottomButtonVisible, isAtBottom { + DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = true } + } else if viewModelContext.scrollToBottomButtonVisible, !isAtBottom { + DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = false } } paginateBackwardsIfNeeded() From 5900d2416555af46dbfc7904e4b8012e1ae37b85 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 2 Dec 2022 11:25:30 +0000 Subject: [PATCH 05/10] Self review. --- .../Screens/RoomScreen/RoomScreenModels.swift | 4 +- .../RoomScreen/RoomScreenViewModel.swift | 16 ++-- .../RoomScreen/View/TimelineTableView.swift | 87 ++++++++++++------- changelog.d/pr-349.change | 1 + 4 files changed, 66 insertions(+), 42 deletions(-) create mode 100644 changelog.d/pr-349.change diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index e6c7106854..27510235c5 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -29,7 +29,7 @@ enum RoomScreenComposerMode: Equatable { } enum RoomScreenViewAction { - case loadPreviousPage + case paginateBackwards case itemAppeared(id: String) case itemDisappeared(id: String) case itemTapped(id: String) @@ -57,8 +57,6 @@ struct RoomScreenViewState: BindableState { bindings.composerText.count == 0 } - let viewActionPublisher = PassthroughSubject() - let paginateBackwardsPublisher = PassthroughSubject() let scrollToBottomPublisher = PassthroughSubject() /// Returns the opacity that the supplied timeline item's cell should be. diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index cf9630cb7e..aed3d1a0f9 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import SwiftUI typealias RoomScreenViewModelType = StateStoreViewModel @@ -26,6 +27,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let timelineController: RoomTimelineControllerProtocol private let timelineViewFactory: RoomTimelineViewFactoryProtocol private let mediaProvider: MediaProviderProtocol + + /// A publisher used to throttle back pagination requests. + private let paginateBackwardsPublisher = PassthroughSubject() // MARK: - Setup @@ -73,19 +77,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.contextMenuBuilder = buildContexMenuForItemId(_:) - state.paginateBackwardsPublisher + paginateBackwardsPublisher .collect(.byTime(DispatchQueue.main, 0.1)) .sink { [weak self] _ in Task { await self?.paginateBackwards() } } .store(in: &cancellables) - state.viewActionPublisher - .sink { [weak self] action in - self?.context.send(viewAction: action) - } - .store(in: &cancellables) - buildTimelineViews() if let roomAvatarUrl { @@ -104,8 +102,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol override func process(viewAction: RoomScreenViewAction) async { switch viewAction { - case .loadPreviousPage: - await paginateBackwards() + case .paginateBackwards: + paginateBackwardsPublisher.send(()) case .itemAppeared(let id): await timelineController.processItemAppearance(id) case .itemDisappeared(let id): diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index 4b7e3b7d91..20238f504e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -17,8 +17,10 @@ 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 = "TimelineCell" + static let reuseIdentifier = "TimelineItemCell" var item: RoomTimelineViewProvider? @@ -27,6 +29,7 @@ class TimelineItemCell: UITableViewCell { } } +/// 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 @@ -38,7 +41,7 @@ struct TimelineTableView: UIViewRepresentable { tableView.allowsSelection = false tableView.keyboardDismissMode = .onDrag context.coordinator.tableView = tableView - viewModelContext.viewState.paginateBackwardsPublisher.send(()) + viewModelContext.send(viewAction: .paginateBackwards) return tableView } @@ -70,7 +73,9 @@ struct TimelineTableView: UIViewRepresentable { var timelineStyle: TimelineStyle = .bubbles var timelineItems: [RoomTimelineViewProvider] = [] { didSet { - guard !adapter.isScrolling.value else { + guard !scrollAdapter.isScrolling.value else { + // Delay updating until scrolling has stopped as programatic + // changes to the scroll position kills any inertia. hasPendingUpdates = true return } @@ -79,27 +84,33 @@ struct TimelineTableView: UIViewRepresentable { } } + /// 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. - 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) + 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. paginateBackwardsIfNeeded() } } + /// The table's diffable data source. private var dataSource: UITableViewDiffableDataSource? private var cancellables: Set = [] - private let adapter = ScrollViewAdapter() + + /// The scroll view adapter used to detect whether scrolling is in progress. + private let scrollAdapter = ScrollViewAdapter() + /// 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? init(viewModelContext: RoomScreenViewModel.Context) { @@ -112,9 +123,10 @@ struct TimelineTableView: UIViewRepresentable { } .store(in: &cancellables) - adapter.isScrolling + 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.paginateBackwardsIfNeeded() @@ -135,10 +147,10 @@ struct TimelineTableView: UIViewRepresentable { cell.contentConfiguration = UIHostingConfiguration { timelineItem .frame(maxWidth: .infinity, alignment: .leading) + .opacity(self.viewModelContext.viewState.opacity(for: timelineItem)) .contextMenu { self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) } - .opacity(self.viewModelContext.viewState.opacity(for: timelineItem)) .onAppear { self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) } @@ -170,8 +182,10 @@ struct TimelineTableView: UIViewRepresentable { frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in guard let self, self.composerMode == .default else { return } - let previousLayout = self.layout() + // 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) } @@ -191,7 +205,7 @@ struct TimelineTableView: UIViewRepresentable { } } - /// Updates the table view with the latest items from the `timelineItems` array. After + /// 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() { @@ -215,6 +229,18 @@ struct TimelineTableView: UIViewRepresentable { } } + /// 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 { @@ -228,12 +254,13 @@ struct TimelineTableView: UIViewRepresentable { return layout } - if let pinnedIndexPath = tableView.indexPathsForVisibleRows?.last, - let pinnedItem = dataSource.itemIdentifier(for: pinnedIndexPath) { - let pinnedCellFrame = tableView.cellFrame(for: pinnedItem) - layout.pinnedItem = PinnedItem(id: pinnedItem.id, position: .bottom, frame: pinnedCellFrame) - layout.isBottomVisible = pinnedItem == snapshot.itemIdentifiers.last - } + 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 } @@ -295,7 +322,7 @@ struct TimelineTableView: UIViewRepresentable { tableView.contentOffset.y < tableView.visibleSize.height * 2.0 else { return } - viewModelContext.viewState.paginateBackwardsPublisher.send(()) + viewModelContext.send(viewAction: .paginateBackwards) } } } @@ -317,38 +344,43 @@ extension TimelineTableView.Coordinator: UITableViewDelegate { // MARK: - ScrollViewAdapter + // Required delegate methods are forwarded to the adapter so others can be implemented. + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - adapter.scrollViewWillBeginDragging(scrollView) + scrollAdapter.scrollViewWillBeginDragging(scrollView) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - adapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate) + scrollAdapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - adapter.scrollViewDidEndScrollingAnimation(scrollView) + scrollAdapter.scrollViewDidEndScrollingAnimation(scrollView) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - adapter.scrollViewDidEndDecelerating(scrollView) + scrollAdapter.scrollViewDidEndDecelerating(scrollView) } func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { - adapter.scrollViewDidScrollToTop(scrollView) + 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 @@ -359,6 +391,7 @@ extension TimelineTableView.Coordinator { // 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 @@ -372,12 +405,6 @@ private extension UITableView { struct TimelineTableView_Previews: PreviewProvider { static var previews: some View { - body.preferredColorScheme(.light) - body.preferredColorScheme(.dark) - } - - @ViewBuilder - static var body: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: MockMediaProvider(), diff --git a/changelog.d/pr-349.change b/changelog.d/pr-349.change new file mode 100644 index 0000000000..7139edc4bd --- /dev/null +++ b/changelog.d/pr-349.change @@ -0,0 +1 @@ +Re-write the timeline view to be backed by a UITableView to fix scroll glitches. \ No newline at end of file From e4c3d908ee7070dd982de8ae3462a2d60a2403c5 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 2 Dec 2022 15:53:22 +0000 Subject: [PATCH 06/10] Merge TimelineView into the RoomScreen. --- ElementX.xcodeproj/project.pbxproj | 12 +-- .../Screens/RoomScreen/View/RoomScreen.swift | 89 +++++++++++-------- .../RoomScreen/View/TimelineView.swift | 54 ----------- 3 files changed, 58 insertions(+), 97 deletions(-) delete mode 100644 ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 074e79ff7c..02ce20a3b9 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -142,7 +142,6 @@ 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 */; }; 50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 518C93DC6516D3D018DE065F /* UNNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */; }; 51DB67C5B5BC68B0A6FF54D4 /* MockRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBDC1D28EFB7789EB467E0 /* MockRoomProxy.swift */; }; @@ -724,7 +723,6 @@ 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 = ""; }; @@ -732,12 +730,12 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; - 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; + 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; @@ -927,7 +925,7 @@ EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1628,7 +1626,6 @@ B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */, - 874A1842477895F199567BD7 /* TimelineView.swift */, A312471EA62EFB0FD94E60DC /* Style */, CCD48459CA34A1928EC7A26A /* Supplementary */, B7D3886505ECC85A06DA8258 /* Timeline */, @@ -2905,7 +2902,6 @@ 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */, FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */, 3B0DF16FEA8481061C28A3BE /* TimelineTableView.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/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index fdb87e518a..9aa13bf945 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -17,51 +17,70 @@ import SwiftUI struct RoomScreen: View { + @ObservedObject private var settings = ElementSettings.shared @ObservedObject var context: RoomScreenViewModel.Context var body: some View { - ZStack { - VStack(spacing: 0.0) { - TimelineView() - .environmentObject(context) - - MessageComposer(text: $context.composerText, - focused: $context.composerFocused, - sendingDisabled: context.viewState.sendButtonDisabled, - type: context.viewState.composerMode) { - sendMessage() - } replyCancellationAction: { - context.send(viewAction: .cancelReply) - } editCancellationAction: { - context.send(viewAction: .cancelEdit) - } - .padding() - } + timeline + .safeAreaInset(edge: .bottom) { messageComposer } .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - RoomHeaderView(context: context) - } - } + .toolbar { toolbar } + .overlay { loadingIndicator } .alert(item: $context.alertInfo) { $0.alert } .sheet(item: $context.debugInfo) { DebugScreen(info: $0) } - - if context.viewState.showLoading { - ProgressView() - .progressViewStyle(.circular) - .tint(.element.primaryContent) - .padding(16) - .background(Color.element.quinaryContent) - .cornerRadius(8) - } + } + + var timeline: some View { + TimelineTableView() + .environmentObject(context) + .timelineStyle(settings.timelineStyle) + .overlay(alignment: .bottomTrailing) { scrollToBottomButton } + } + + var messageComposer: some View { + MessageComposer(text: $context.composerText, + focused: $context.composerFocused, + sendingDisabled: context.viewState.sendButtonDisabled, + type: context.viewState.composerMode) { + sendMessage() + } replyCancellationAction: { + context.send(viewAction: .cancelReply) + } editCancellationAction: { + context.send(viewAction: .cancelEdit) } + .padding() } - private func sendMessage() { - guard !context.viewState.sendButtonDisabled else { - return + var scrollToBottomButton: some View { + Button { context.viewState.scrollToBottomPublisher.send(()) } label: { + Image(uiImage: Asset.Images.timelineScrollToBottom.image) + .shadow(radius: 2.0) + .padding() } - + .opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0) + .animation(.elementDefault, value: context.scrollToBottomButtonVisible) + } + + @ViewBuilder + var loadingIndicator: some View { + if context.viewState.showLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.element.primaryContent) + .padding(16) + .background(Color.element.quinaryContent) + .cornerRadius(8) + } + } + + var toolbar: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + RoomHeaderView(context: context) + } + } + + private func sendMessage() { + guard !context.viewState.sendButtonDisabled else { return } context.send(viewAction: .sendMessage) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift deleted file mode 100644 index 5619a9724d..0000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ /dev/null @@ -1,54 +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 Foundation -import SwiftUI - -import Introspect - -struct TimelineView: View { - @ObservedObject private var settings = ElementSettings.shared - @EnvironmentObject private var context: RoomScreenViewModel.Context - - var body: some View { - TimelineTableView() - .timelineStyle(settings.timelineStyle) - .overlay(alignment: .bottomTrailing) { scrollToBottomButton } - } - - private var scrollToBottomButton: some View { - Button { context.viewState.scrollToBottomPublisher.send(()) } label: { - Image(uiImage: Asset.Images.timelineScrollToBottom.image) - .shadow(radius: 2.0) - .padding() - } - .opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0) - .animation(.elementDefault, value: context.scrollToBottomButtonVisible) - } -} - -struct TimelineView_Previews: PreviewProvider { - static var previews: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), - mediaProvider: MockMediaProvider(), - roomName: nil) - - TimelineView() - .environmentObject(viewModel.context) - } -} From 81d98c00ddf278e2b8a76ab90ef2145fc93edfd7 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 2 Dec 2022 16:16:09 +0000 Subject: [PATCH 07/10] Fix Swift 6 concurrency warnings. --- .../Screens/RoomScreen/View/TimelineTableView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index 20238f504e..f266e8b5ea 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -181,7 +181,14 @@ struct TimelineTableView: UIViewRepresentable { frameObserverToken?.invalidate() frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in - guard let self, self.composerMode == .default else { return } + 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. From e5062dcf06a6013256305c8d99ccda8ecc87740c Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 5 Dec 2022 12:43:43 +0000 Subject: [PATCH 08/10] Fix retain cycle in the hosting configuration. --- .../RoomScreen/View/TimelineTableView.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index f266e8b5ea..84e63ebdff 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -138,31 +138,33 @@ struct TimelineTableView: UIViewRepresentable { private func configureDataSource() { guard let tableView else { return } - dataSource = .init(tableView: tableView) { tableView, indexPath, timelineItem in + dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath) - guard let cell = cell as? TimelineItemCell else { return cell } + 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 - #warning("Do we need a weak self here???") cell.contentConfiguration = UIHostingConfiguration { timelineItem .frame(maxWidth: .infinity, alignment: .leading) - .opacity(self.viewModelContext.viewState.opacity(for: timelineItem)) + .opacity(viewModelContext.viewState.opacity(for: timelineItem)) .contextMenu { - self.viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) + viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) } .onAppear { - self.viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) + viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) } .onDisappear { - self.viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) + viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) } .environment(\.openURL, OpenURLAction { url in - self.viewModelContext.send(viewAction: .linkClicked(url: url)) + viewModelContext.send(viewAction: .linkClicked(url: url)) return .systemAction }) .onTapGesture { - self.viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) + viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) } } .margins(.all, self.timelineStyle.rowInsets) From 90ae3dd770f003bf9ac0ca20481af8d58044d5f4 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 5 Dec 2022 14:49:41 +0000 Subject: [PATCH 09/10] Address PR comments. --- .../RoomScreen/RoomScreenViewModel.swift | 17 ++++------------ .../RoomScreen/View/TimelineTableView.swift | 20 ++++++++++++++++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index aed3d1a0f9..b66af880e1 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -22,14 +22,12 @@ typealias RoomScreenViewModelType = StateStoreViewModel() // MARK: - Setup @@ -64,26 +62,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem) case .startedBackPaginating: self.state.isBackPaginating = true - ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: "pagination", + ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Constants.backPaginationIndicatorID, type: .toast, title: ElementL10n.roomTimelineSyncing, persistent: true)) case .finishedBackPaginating: self.state.isBackPaginating = false - ServiceLocator.shared.userNotificationController.retractNotificationWithId("pagination") + ServiceLocator.shared.userNotificationController.retractNotificationWithId(Constants.backPaginationIndicatorID) } } .store(in: &cancellables) state.contextMenuBuilder = buildContexMenuForItemId(_:) - paginateBackwardsPublisher - .collect(.byTime(DispatchQueue.main, 0.1)) - .sink { [weak self] _ in - Task { await self?.paginateBackwards() } - } - .store(in: &cancellables) - buildTimelineViews() if let roomAvatarUrl { @@ -103,7 +94,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol override func process(viewAction: RoomScreenViewAction) async { switch viewAction { case .paginateBackwards: - paginateBackwardsPublisher.send(()) + await paginateBackwards() case .itemAppeared(let id): await timelineController.processItemAppearance(id) case .itemDisappeared(let id): diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index 84e63ebdff..88c0831c92 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -98,7 +98,7 @@ struct TimelineTableView: UIViewRepresentable { var isBackPaginating = false { didSet { // Paginate again if the threshold hasn't been satisfied. - paginateBackwardsIfNeeded() + paginateBackwardsPublisher.send(()) } } @@ -108,6 +108,11 @@ struct TimelineTableView: UIViewRepresentable { /// 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. @@ -129,7 +134,14 @@ struct TimelineTableView: UIViewRepresentable { // When scrolling has stopped, apply any pending updates. self.applySnapshot() self.hasPendingUpdates = false - self.paginateBackwardsIfNeeded() + self.paginateBackwardsPublisher.send(()) + } + .store(in: &cancellables) + + paginateBackwardsPublisher + .collect(.byTime(DispatchQueue.main, 0.1)) + .sink { [weak self] _ in + self?.paginateBackwardsIfNeeded() } .store(in: &cancellables) } @@ -324,6 +336,8 @@ struct TimelineTableView: UIViewRepresentable { } /// 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, @@ -348,7 +362,7 @@ extension TimelineTableView.Coordinator: UITableViewDelegate { DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = false } } - paginateBackwardsIfNeeded() + paginateBackwardsPublisher.send(()) } // MARK: - ScrollViewAdapter From 2469f897cfd0b9a40c3aa7b7471dfb5ffe16fa44 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 5 Dec 2022 15:04:59 +0000 Subject: [PATCH 10/10] Fix scrolling on keyboard appearance. --- .../RoomScreen/View/TimelineTableView.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift index 88c0831c92..edb35a8b89 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -117,6 +117,8 @@ struct TimelineTableView: UIViewRepresentable { 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 @@ -144,6 +146,20 @@ struct TimelineTableView: UIViewRepresentable { 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.