mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Use a view controller for the timeline. (#359)
This exposes lifecycle methods and tidies up the Coordinator.
This commit is contained in:
@@ -120,7 +120,6 @@
|
|||||||
39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; };
|
39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; };
|
||||||
3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; };
|
3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201305507D7DFD16E544563A /* EmojiLoaderProtocol.swift */; };
|
||||||
3A64A93A651A3CB8774ADE8E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; };
|
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 */; };
|
3B770CB4DED51CC362C66D47 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */; };
|
||||||
3C549A0BF39F8A854D45D9FD /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; };
|
3C549A0BF39F8A854D45D9FD /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; };
|
||||||
3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; };
|
3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; };
|
||||||
@@ -149,6 +148,7 @@
|
|||||||
4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; };
|
4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; };
|
||||||
4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */; };
|
4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */; };
|
||||||
4FF90E2242DBD596E1ED2E27 /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */; };
|
4FF90E2242DBD596E1ED2E27 /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */; };
|
||||||
|
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 874A1842477895F199567BD7 /* TimelineView.swift */; };
|
||||||
501304F26B52DF7024011B6C /* EmojiMartJSONLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */; };
|
501304F26B52DF7024011B6C /* EmojiMartJSONLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */; };
|
||||||
50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; };
|
50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; };
|
||||||
518C93DC6516D3D018DE065F /* UNNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */; };
|
518C93DC6516D3D018DE065F /* UNNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */; };
|
||||||
@@ -207,6 +207,7 @@
|
|||||||
6F2AB43A1EFAD8A97AF41A15 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = A7CA6F33C553805035C3B114 /* DeviceKit */; };
|
6F2AB43A1EFAD8A97AF41A15 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = A7CA6F33C553805035C3B114 /* DeviceKit */; };
|
||||||
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; };
|
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; };
|
||||||
7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; };
|
7002C55A4C917F3715765127 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; };
|
||||||
|
702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */; };
|
||||||
706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */; };
|
706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */; };
|
||||||
7096FA3AC218D914E88BFB70 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BE37BE2FB86E00C8D150A /* AggregratedReaction.swift */; };
|
7096FA3AC218D914E88BFB70 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BE37BE2FB86E00C8D150A /* AggregratedReaction.swift */; };
|
||||||
719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; };
|
719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; };
|
||||||
@@ -551,7 +552,6 @@
|
|||||||
142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = "<group>"; };
|
142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = "<group>"; };
|
||||||
167521635A1CC27624FCEB7F /* ServerSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModel.swift; sourceTree = "<group>"; };
|
167521635A1CC27624FCEB7F /* ServerSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModel.swift; sourceTree = "<group>"; };
|
||||||
16DC8C5B2991724903F1FA6A /* AppIcon.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = AppIcon.pdf; sourceTree = "<group>"; };
|
16DC8C5B2991724903F1FA6A /* AppIcon.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = AppIcon.pdf; sourceTree = "<group>"; };
|
||||||
170A84E8957BF97A26E962D5 /* TimelineTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableView.swift; sourceTree = "<group>"; };
|
|
||||||
1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = "<group>"; };
|
1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = "<group>"; };
|
||||||
1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = "<group>"; };
|
1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = "<group>"; };
|
||||||
179423E34EE846E048E49CBF /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = "<group>"; };
|
179423E34EE846E048E49CBF /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = "<group>"; };
|
||||||
@@ -753,6 +753,7 @@
|
|||||||
858F8D0B0D51CC41BAA18E24 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
858F8D0B0D51CC41BAA18E24 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
85CB1DDCEE53B946D09DF4F6 /* bn-BD */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-BD"; path = "bn-BD.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
85CB1DDCEE53B946D09DF4F6 /* bn-BD */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-BD"; path = "bn-BD.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
873718F8BD17B778C5141C45 /* ta */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ta; path = ta.lproj/Localizable.strings; sourceTree = "<group>"; };
|
873718F8BD17B778C5141C45 /* ta */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ta; path = ta.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
||||||
878B7C1885486FB4BE41631D /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = iw; path = iw.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
878B7C1885486FB4BE41631D /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = iw; path = iw.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||||
885D8C42DD17625B5261BEFF /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = "<group>"; };
|
885D8C42DD17625B5261BEFF /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = "<group>"; };
|
||||||
8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = "<group>"; };
|
8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = "<group>"; };
|
||||||
@@ -995,6 +996,7 @@
|
|||||||
F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkBuilder.swift; sourceTree = "<group>"; };
|
F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkBuilder.swift; sourceTree = "<group>"; };
|
||||||
F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = "<group>"; };
|
F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||||
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
|
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||||
|
F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
|
||||||
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = "<group>"; };
|
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||||
FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = "<group>"; };
|
FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = "<group>"; };
|
||||||
FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = "<group>"; };
|
FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = "<group>"; };
|
||||||
@@ -1720,7 +1722,8 @@
|
|||||||
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */,
|
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */,
|
||||||
B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */,
|
B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */,
|
||||||
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
||||||
170A84E8957BF97A26E962D5 /* TimelineTableView.swift */,
|
F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */,
|
||||||
|
874A1842477895F199567BD7 /* TimelineView.swift */,
|
||||||
A312471EA62EFB0FD94E60DC /* Style */,
|
A312471EA62EFB0FD94E60DC /* Style */,
|
||||||
CCD48459CA34A1928EC7A26A /* Supplementary */,
|
CCD48459CA34A1928EC7A26A /* Supplementary */,
|
||||||
B7D3886505ECC85A06DA8258 /* Timeline */,
|
B7D3886505ECC85A06DA8258 /* Timeline */,
|
||||||
@@ -3033,7 +3036,8 @@
|
|||||||
ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */,
|
ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */,
|
||||||
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
|
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
|
||||||
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
|
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
|
||||||
3B0DF16FEA8481061C28A3BE /* TimelineTableView.swift in Sources */,
|
702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */,
|
||||||
|
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
|
||||||
7732B2F635626BE1C1CD92A4 /* UIActivityViewControllerWrapper.swift in Sources */,
|
7732B2F635626BE1C1CD92A4 /* UIActivityViewControllerWrapper.swift in Sources */,
|
||||||
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,
|
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,
|
||||||
A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */,
|
A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */,
|
||||||
|
|||||||
@@ -63,12 +63,6 @@ struct RoomScreenViewState: BindableState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
/// 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 {
|
struct RoomScreenViewStateBindings {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ struct RoomScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var timeline: some View {
|
var timeline: some View {
|
||||||
TimelineTableView()
|
TimelineView()
|
||||||
.id(context.viewState.roomId)
|
.id(context.viewState.roomId)
|
||||||
.environmentObject(context)
|
.environmentObject(context)
|
||||||
.timelineStyle(settings.timelineStyle)
|
.timelineStyle(settings.timelineStyle)
|
||||||
|
|||||||
@@ -1,477 +0,0 @@
|
|||||||
//
|
|
||||||
// Copyright 2022 New Vector Ltd
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A table view cell that displays a timeline item in a room. The cell is intended
|
|
||||||
/// to be configured to display a SwiftUI view and not use any UIKit.
|
|
||||||
class TimelineItemCell: UITableViewCell {
|
|
||||||
static let reuseIdentifier = "TimelineItemCell"
|
|
||||||
|
|
||||||
var item: RoomTimelineViewProvider?
|
|
||||||
|
|
||||||
override func prepareForReuse() {
|
|
||||||
item = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A table view wrapper that displays the timeline of a room.
|
|
||||||
struct TimelineTableView: UIViewRepresentable {
|
|
||||||
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
|
|
||||||
@Environment(\.timelineStyle) private var timelineStyle
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITableView {
|
|
||||||
let tableView = UITableView(frame: .zero, style: .plain)
|
|
||||||
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
|
|
||||||
tableView.separatorStyle = .none
|
|
||||||
tableView.allowsSelection = false
|
|
||||||
tableView.keyboardDismissMode = .onDrag
|
|
||||||
context.coordinator.tableView = tableView
|
|
||||||
viewModelContext.send(viewAction: .paginateBackwards)
|
|
||||||
return tableView
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UITableView, context: Context) {
|
|
||||||
context.coordinator.update()
|
|
||||||
|
|
||||||
if context.coordinator.timelineStyle != timelineStyle {
|
|
||||||
context.coordinator.timelineStyle = timelineStyle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(viewModelContext: viewModelContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Coordinator
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class Coordinator: NSObject {
|
|
||||||
let viewModelContext: RoomScreenViewModel.Context
|
|
||||||
|
|
||||||
var tableView: UITableView? {
|
|
||||||
didSet {
|
|
||||||
registerFrameObserver()
|
|
||||||
configureDataSource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var timelineStyle: TimelineStyle = .bubbles
|
|
||||||
var timelineItems: [RoomTimelineViewProvider] = [] {
|
|
||||||
didSet {
|
|
||||||
guard !scrollAdapter.isScrolling.value else {
|
|
||||||
// Delay updating until scrolling has stopped as programatic
|
|
||||||
// changes to the scroll position kills any inertia.
|
|
||||||
hasPendingUpdates = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applySnapshot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The mode of the message composer. This is used to render selected
|
|
||||||
/// items in the timeline when replying, editing etc.
|
|
||||||
var composerMode: RoomScreenComposerMode = .default {
|
|
||||||
didSet {
|
|
||||||
// Reload the visible items in order to update their opacity.
|
|
||||||
// Applying a snapshot won't work in this instance as the items don't change.
|
|
||||||
reloadVisibleItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether or not the timeline is waiting for more messages to be added to the top.
|
|
||||||
var isBackPaginating = false {
|
|
||||||
didSet {
|
|
||||||
// Paginate again if the threshold hasn't been satisfied.
|
|
||||||
paginateBackwardsPublisher.send(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var displayReactionsMenuForItemId = "" {
|
|
||||||
didSet {
|
|
||||||
tableView?.reloadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The table's diffable data source.
|
|
||||||
private var dataSource: UITableViewDiffableDataSource<TimelineSection, RoomTimelineViewProvider>?
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
/// 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<Void, Never>()
|
|
||||||
/// Whether or not the ``timelineItems`` value should be applied when scrolling stops.
|
|
||||||
private var hasPendingUpdates = false
|
|
||||||
/// The observation token used to handle frame changes.
|
|
||||||
private var frameObserverToken: NSKeyValueObservation?
|
|
||||||
/// Yucky hack to fix some layouts where the scroll view doesn't make it to the bottom on keyboard appearance.
|
|
||||||
var keyboardWillShowLayout: LayoutDescriptor?
|
|
||||||
|
|
||||||
init(viewModelContext: RoomScreenViewModel.Context) {
|
|
||||||
self.viewModelContext = viewModelContext
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
viewModelContext.viewState.scrollToBottomPublisher
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.scrollToBottom(animated: true)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
scrollAdapter.isScrolling
|
|
||||||
.sink { [weak self] isScrolling in
|
|
||||||
guard !isScrolling, let self, self.hasPendingUpdates else { return }
|
|
||||||
// When scrolling has stopped, apply any pending updates.
|
|
||||||
self.applySnapshot()
|
|
||||||
self.hasPendingUpdates = false
|
|
||||||
self.paginateBackwardsPublisher.send(())
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
paginateBackwardsPublisher
|
|
||||||
.collect(.byTime(DispatchQueue.main, 0.1))
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.paginateBackwardsIfNeeded()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.keyboardWillShowLayout = self.layout()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self, let layout = self.keyboardWillShowLayout, layout.isBottomVisible else { return }
|
|
||||||
self.scrollToBottom(animated: false) // Force the bottom to be visible as some timelines misbehave.
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configures a diffable data source for the timeline's table view.
|
|
||||||
private func configureDataSource() {
|
|
||||||
guard let tableView else { return }
|
|
||||||
|
|
||||||
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
|
||||||
guard let self, let cell = cell as? TimelineItemCell else { return cell }
|
|
||||||
|
|
||||||
// A local reference to avoid capturing self in the cell configuration.
|
|
||||||
let viewModelContext = self.viewModelContext
|
|
||||||
|
|
||||||
cell.item = timelineItem
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
|
||||||
VStack {
|
|
||||||
if viewModelContext.viewState.displayReactionsMenuForItemId == timelineItem.id {
|
|
||||||
TimelineItemReactionsMenuView(onEmojiSelected: { emoji in
|
|
||||||
viewModelContext.send(viewAction: .emojiTapped(emoji: emoji, itemId: timelineItem.id))
|
|
||||||
}, onDisplayEmojiPicker: {
|
|
||||||
viewModelContext.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
timelineItem
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.opacity(viewModelContext.viewState.opacity(for: timelineItem))
|
|
||||||
.contextMenu {
|
|
||||||
viewModelContext.viewState.contextMenuBuilder?(timelineItem.id)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id))
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
|
||||||
}
|
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
viewModelContext.send(viewAction: .linkClicked(url: url))
|
|
||||||
return .systemAction
|
|
||||||
})
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
viewModelContext.send(viewAction: .displayReactionsMenuForItemId(itemId: timelineItem.id))
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.margins(.all, self.timelineStyle.rowInsets)
|
|
||||||
.minSize(height: 1)
|
|
||||||
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
tableView.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds an observer on the frame of the table view in order to keep the
|
|
||||||
/// last item visible when the keyboard is shown or the window resizes.
|
|
||||||
private func registerFrameObserver() {
|
|
||||||
// Remove the existing observer if necessary
|
|
||||||
frameObserverToken?.invalidate()
|
|
||||||
|
|
||||||
frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in
|
|
||||||
self?.handleFrameChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the table's layout if necessary after the frame changed.
|
|
||||||
private nonisolated func handleFrameChange() {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard self.composerMode == .default else { return }
|
|
||||||
|
|
||||||
// The table view is yet to update its layout so layout() returns a
|
|
||||||
// description of the timeline before the frame change occurs.
|
|
||||||
let previousLayout = self.layout()
|
|
||||||
if previousLayout.isBottomVisible {
|
|
||||||
self.scrollToBottom(animated: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the table view's internal state from the view model's context.
|
|
||||||
func update() {
|
|
||||||
if timelineItems != viewModelContext.viewState.items {
|
|
||||||
timelineItems = viewModelContext.viewState.items
|
|
||||||
}
|
|
||||||
if isBackPaginating != viewModelContext.viewState.isBackPaginating {
|
|
||||||
isBackPaginating = viewModelContext.viewState.isBackPaginating
|
|
||||||
}
|
|
||||||
if composerMode != viewModelContext.viewState.composerMode {
|
|
||||||
composerMode = viewModelContext.viewState.composerMode
|
|
||||||
}
|
|
||||||
if displayReactionsMenuForItemId != viewModelContext.viewState.displayReactionsMenuForItemId {
|
|
||||||
displayReactionsMenuForItemId = viewModelContext.viewState.displayReactionsMenuForItemId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the table view with the latest items from the ``timelineItems`` array. After
|
|
||||||
/// updating the data, the table will be scrolled to the bottom if it was visible otherwise
|
|
||||||
/// the scroll position will be updated to maintain the position of the last visible item.
|
|
||||||
private func applySnapshot() {
|
|
||||||
guard let dataSource else { return }
|
|
||||||
|
|
||||||
let previousLayout = layout()
|
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>()
|
|
||||||
snapshot.appendSections([.main])
|
|
||||||
snapshot.appendItems(timelineItems)
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
|
||||||
|
|
||||||
updateTopPadding()
|
|
||||||
|
|
||||||
guard snapshot.numberOfItems != previousLayout.numberOfItems else { return }
|
|
||||||
|
|
||||||
if previousLayout.isBottomVisible {
|
|
||||||
scrollToBottom(animated: false)
|
|
||||||
} else if let pinnedItem = previousLayout.pinnedItem {
|
|
||||||
restoreScrollPosition(using: pinnedItem, and: snapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reloads all of the visible timeline items.
|
|
||||||
///
|
|
||||||
/// This only needs to be called when some state internal to this table view changes that
|
|
||||||
/// will affect the appearance of those items. Any updates to the items themselves should
|
|
||||||
/// use ``applySnapshot()`` which handles everything in the diffable data source.
|
|
||||||
private func reloadVisibleItems() {
|
|
||||||
guard let tableView, let visibleIndexPaths = tableView.indexPathsForVisibleRows, let dataSource else { return }
|
|
||||||
var snapshot = dataSource.snapshot()
|
|
||||||
snapshot.reloadItems(visibleIndexPaths.compactMap { dataSource.itemIdentifier(for: $0) })
|
|
||||||
dataSource.apply(snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a description of the current layout in order to update the
|
|
||||||
/// scroll position after adding/updating items to the timeline.
|
|
||||||
private func layout() -> LayoutDescriptor {
|
|
||||||
guard let tableView, let dataSource else { return LayoutDescriptor() }
|
|
||||||
|
|
||||||
let snapshot = dataSource.snapshot()
|
|
||||||
var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems)
|
|
||||||
|
|
||||||
guard !snapshot.itemIdentifiers.isEmpty else {
|
|
||||||
layout.isBottomVisible = true
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last,
|
|
||||||
let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath)
|
|
||||||
else { return layout }
|
|
||||||
|
|
||||||
let bottomCellFrame = tableView.cellFrame(for: bottomItem)
|
|
||||||
layout.pinnedItem = PinnedItem(id: bottomItem.id, position: .bottom, frame: bottomCellFrame)
|
|
||||||
layout.isBottomVisible = bottomItem == snapshot.itemIdentifiers.last
|
|
||||||
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the additional padding added to the top of the table (via a header)
|
|
||||||
/// in order to fill the timeline from the bottom of the view upwards.
|
|
||||||
private func updateTopPadding() {
|
|
||||||
guard let tableView else { return }
|
|
||||||
|
|
||||||
let contentHeight = tableView.contentSize.height - (tableView.tableHeaderView?.frame.height ?? 0)
|
|
||||||
let height = tableView.visibleSize.height - contentHeight
|
|
||||||
|
|
||||||
if height > 0 {
|
|
||||||
let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height))
|
|
||||||
tableView.tableHeaderView = UIView(frame: frame) // Updating an existing view's height doesn't move the cells.
|
|
||||||
} else {
|
|
||||||
tableView.tableHeaderView = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether or not the bottom of the scroll view is visible (with some small tolerance added).
|
|
||||||
private func isAtBottom(of scrollView: UIScrollView) -> Bool {
|
|
||||||
scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.visibleSize.height - 15)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scrolls to the bottom of the timeline.
|
|
||||||
private func scrollToBottom(animated: Bool) {
|
|
||||||
guard let lastItem = timelineItems.last,
|
|
||||||
let lastIndexPath = dataSource?.indexPath(for: lastItem)
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
tableView?.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restores the position of the timeline using the supplied item and snapshot.
|
|
||||||
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>) {
|
|
||||||
guard let tableView,
|
|
||||||
let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }),
|
|
||||||
let indexPath = dataSource?.indexPath(for: item)
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
// Scroll the item into view.
|
|
||||||
tableView.scrollToRow(at: indexPath, at: pinnedItem.position, animated: false)
|
|
||||||
|
|
||||||
guard let oldFrame = pinnedItem.frame, let newFrame = tableView.cellFrame(for: item) else { return }
|
|
||||||
|
|
||||||
// Remove any unwanted offset that was added by scrollToRow.
|
|
||||||
let deltaY = newFrame.maxY - oldFrame.maxY
|
|
||||||
if deltaY != 0 {
|
|
||||||
tableView.contentOffset.y += deltaY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks whether or a backwards pagination is needed and requests one if so.
|
|
||||||
///
|
|
||||||
/// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests.
|
|
||||||
private func paginateBackwardsIfNeeded() {
|
|
||||||
guard let tableView,
|
|
||||||
!isBackPaginating,
|
|
||||||
!hasPendingUpdates,
|
|
||||||
tableView.contentOffset.y < tableView.visibleSize.height * 2.0
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
viewModelContext.send(viewAction: .paginateBackwards)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
|
||||||
|
|
||||||
extension TimelineTableView.Coordinator: UITableViewDelegate {
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
||||||
let isAtBottom = isAtBottom(of: scrollView)
|
|
||||||
|
|
||||||
if !viewModelContext.scrollToBottomButtonVisible, isAtBottom {
|
|
||||||
DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = true }
|
|
||||||
} else if viewModelContext.scrollToBottomButtonVisible, !isAtBottom {
|
|
||||||
DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
paginateBackwardsPublisher.send(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ScrollViewAdapter
|
|
||||||
|
|
||||||
// Required delegate methods are forwarded to the adapter so others can be implemented.
|
|
||||||
|
|
||||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
||||||
scrollAdapter.scrollViewWillBeginDragging(scrollView)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
||||||
scrollAdapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
|
||||||
scrollAdapter.scrollViewDidEndScrollingAnimation(scrollView)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
||||||
scrollAdapter.scrollViewDidEndDecelerating(scrollView)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
|
|
||||||
scrollAdapter.scrollViewDidScrollToTop(scrollView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Layout Types
|
|
||||||
|
|
||||||
extension TimelineTableView.Coordinator {
|
|
||||||
/// The sections of the table view used in the diffable data source.
|
|
||||||
enum TimelineSection { case main }
|
|
||||||
|
|
||||||
/// A description of the timeline's layout.
|
|
||||||
struct LayoutDescriptor {
|
|
||||||
var numberOfItems = 0
|
|
||||||
var pinnedItem: PinnedItem?
|
|
||||||
var isBottomVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An item that should have its position pinned after updates.
|
|
||||||
struct PinnedItem {
|
|
||||||
let id: String
|
|
||||||
let position: UITableView.ScrollPosition
|
|
||||||
let frame: CGRect?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cell Layout
|
|
||||||
|
|
||||||
private extension UITableView {
|
|
||||||
/// Returns the frame of the cell for a particular timeline item.
|
|
||||||
func cellFrame(for item: RoomTimelineViewProvider) -> CGRect? {
|
|
||||||
guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item == item }) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return convert(timelineCell.frame, to: superview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews
|
|
||||||
|
|
||||||
struct TimelineTableView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
|
||||||
timelineViewFactory: RoomTimelineViewFactory(),
|
|
||||||
mediaProvider: MockMediaProvider(),
|
|
||||||
roomName: "Preview room")
|
|
||||||
|
|
||||||
NavigationView {
|
|
||||||
RoomScreen(context: viewModel.context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2022 New Vector Ltd
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A table view cell that displays a timeline item in a room. The cell is intended
|
||||||
|
/// to be configured to display a SwiftUI view and not use any UIKit.
|
||||||
|
class TimelineItemCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "TimelineItemCell"
|
||||||
|
|
||||||
|
var item: RoomTimelineViewProvider?
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
item = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table view controller that displays the timeline of a room.
|
||||||
|
///
|
||||||
|
/// This class subclasses `UIViewController` as `UITableViewController` adds some
|
||||||
|
/// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1).
|
||||||
|
class TimelineTableViewController: UIViewController {
|
||||||
|
let coordinator: TimelineView.Coordinator
|
||||||
|
let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
|
||||||
|
var timelineStyle: TimelineStyle
|
||||||
|
var timelineItems: [RoomTimelineViewProvider] = [] {
|
||||||
|
didSet {
|
||||||
|
guard !scrollAdapter.isScrolling.value else {
|
||||||
|
// Delay updating until scrolling has stopped as programatic
|
||||||
|
// changes to the scroll position kills any inertia.
|
||||||
|
hasPendingUpdates = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applySnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode of the message composer. This is used to render selected
|
||||||
|
/// items in the timeline when replying, editing etc.
|
||||||
|
var composerMode: RoomScreenComposerMode = .default {
|
||||||
|
didSet {
|
||||||
|
// Reload the visible items in order to update their opacity.
|
||||||
|
// Applying a snapshot won't work in this instance as the items don't change.
|
||||||
|
reloadVisibleItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not the timeline is waiting for more messages to be added to the top.
|
||||||
|
var isBackPaginating = false {
|
||||||
|
didSet {
|
||||||
|
// Paginate again if the threshold hasn't been satisfied.
|
||||||
|
paginateBackwardsPublisher.send(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayReactionsMenuForItemId = "" {
|
||||||
|
didSet {
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
|
||||||
|
|
||||||
|
@Binding private var scrollToBottomButtonVisible: Bool
|
||||||
|
|
||||||
|
/// The table's diffable data source.
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<TimelineSection, RoomTimelineViewProvider>?
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
|
/// 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<Void, Never>()
|
||||||
|
/// Whether or not the ``timelineItems`` value should be applied when scrolling stops.
|
||||||
|
private var hasPendingUpdates = false
|
||||||
|
/// Yucky hack to fix some layouts where the scroll view doesn't make it to the bottom on keyboard appearance.
|
||||||
|
private var keyboardWillShowLayout: LayoutDescriptor?
|
||||||
|
/// Whether or not the view has been shown on screen yet.
|
||||||
|
private var hasAppearedOnce = false
|
||||||
|
|
||||||
|
init(coordinator: TimelineView.Coordinator,
|
||||||
|
timelineStyle: TimelineStyle,
|
||||||
|
scrollToBottomButtonVisible: Binding<Bool>,
|
||||||
|
scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.timelineStyle = timelineStyle
|
||||||
|
_scrollToBottomButtonVisible = scrollToBottomButtonVisible
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.allowsSelection = false
|
||||||
|
tableView.keyboardDismissMode = .onDrag
|
||||||
|
view.addSubview(tableView)
|
||||||
|
|
||||||
|
scrollToBottomPublisher
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.scrollToBottom(animated: true)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
scrollAdapter.isScrolling
|
||||||
|
.sink { [weak self] isScrolling in
|
||||||
|
guard !isScrolling, let self, self.hasPendingUpdates else { return }
|
||||||
|
// When scrolling has stopped, apply any pending updates.
|
||||||
|
self.applySnapshot()
|
||||||
|
self.hasPendingUpdates = false
|
||||||
|
self.paginateBackwardsPublisher.send(())
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
paginateBackwardsPublisher
|
||||||
|
.collect(.byTime(DispatchQueue.main, 0.1))
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.paginateBackwardsIfNeeded()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.keyboardWillShowLayout = self.layout()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self, let layout = self.keyboardWillShowLayout, layout.isBottomVisible else { return }
|
||||||
|
self.scrollToBottom(animated: false) // Force the bottom to be visible as some timelines misbehave.
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
configureDataSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not available.") }
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
guard !hasAppearedOnce else { return }
|
||||||
|
scrollToBottom(animated: false)
|
||||||
|
hasAppearedOnce = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillLayoutSubviews() {
|
||||||
|
super.viewWillLayoutSubviews()
|
||||||
|
|
||||||
|
guard tableView.frame.size != view.frame.size else { return }
|
||||||
|
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
|
||||||
|
|
||||||
|
// Update the table's layout if necessary after the frame changed.
|
||||||
|
updateTopPadding()
|
||||||
|
|
||||||
|
guard composerMode == .default else { return }
|
||||||
|
|
||||||
|
// The table view is yet to update its content so layout() returns a
|
||||||
|
// description of the timeline before the frame change occurs.
|
||||||
|
let previousLayout = layout()
|
||||||
|
if previousLayout.isBottomVisible {
|
||||||
|
scrollToBottom(animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures a diffable data source for the timeline's table view.
|
||||||
|
private func configureDataSource() {
|
||||||
|
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
||||||
|
guard let self, let cell = cell as? TimelineItemCell else { return cell }
|
||||||
|
|
||||||
|
// A local reference to avoid capturing self in the cell configuration.
|
||||||
|
let coordinator = self.coordinator
|
||||||
|
let opacity = self.opacity(for: timelineItem)
|
||||||
|
let displayReactionsMenuForItemId = self.displayReactionsMenuForItemId
|
||||||
|
let contextMenuBuilder = self.contextMenuBuilder
|
||||||
|
|
||||||
|
cell.item = timelineItem
|
||||||
|
cell.contentConfiguration = UIHostingConfiguration {
|
||||||
|
VStack {
|
||||||
|
if displayReactionsMenuForItemId == timelineItem.id {
|
||||||
|
TimelineItemReactionsMenuView { emoji in
|
||||||
|
coordinator.send(viewAction: .emojiTapped(emoji: emoji, itemId: timelineItem.id))
|
||||||
|
} onDisplayEmojiPicker: {
|
||||||
|
coordinator.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timelineItem
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.opacity(opacity)
|
||||||
|
.contextMenu {
|
||||||
|
contextMenuBuilder?(timelineItem.id)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
||||||
|
}
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
coordinator.send(viewAction: .linkClicked(url: url))
|
||||||
|
return .systemAction
|
||||||
|
})
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
coordinator.send(viewAction: .displayReactionsMenuForItemId(itemId: timelineItem.id))
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
coordinator.send(viewAction: .itemTapped(id: timelineItem.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.margins(.all, self.timelineStyle.rowInsets)
|
||||||
|
.minSize(height: 1)
|
||||||
|
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
tableView.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the table view with the latest items from the ``timelineItems`` array. After
|
||||||
|
/// updating the data, the table will be scrolled to the bottom if it was visible otherwise
|
||||||
|
/// the scroll position will be updated to maintain the position of the last visible item.
|
||||||
|
private func applySnapshot() {
|
||||||
|
guard let dataSource else { return }
|
||||||
|
|
||||||
|
let previousLayout = layout()
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(timelineItems)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
|
updateTopPadding()
|
||||||
|
|
||||||
|
guard snapshot.numberOfItems != previousLayout.numberOfItems else { return }
|
||||||
|
|
||||||
|
if previousLayout.isBottomVisible {
|
||||||
|
scrollToBottom(animated: false)
|
||||||
|
} else if let pinnedItem = previousLayout.pinnedItem {
|
||||||
|
restoreScrollPosition(using: pinnedItem, and: snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reloads all of the visible timeline items.
|
||||||
|
///
|
||||||
|
/// This only needs to be called when some state internal to this table view changes that
|
||||||
|
/// will affect the appearance of those items. Any updates to the items themselves should
|
||||||
|
/// use ``applySnapshot()`` which handles everything in the diffable data source.
|
||||||
|
private func reloadVisibleItems() {
|
||||||
|
guard let visibleIndexPaths = tableView.indexPathsForVisibleRows, let dataSource else { return }
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reloadItems(visibleIndexPaths.compactMap { dataSource.itemIdentifier(for: $0) })
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a description of the current layout in order to update the
|
||||||
|
/// scroll position after adding/updating items to the timeline.
|
||||||
|
private func layout() -> LayoutDescriptor {
|
||||||
|
guard let dataSource else { return LayoutDescriptor() }
|
||||||
|
|
||||||
|
let snapshot = dataSource.snapshot()
|
||||||
|
var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems)
|
||||||
|
|
||||||
|
guard !snapshot.itemIdentifiers.isEmpty else {
|
||||||
|
layout.isBottomVisible = true
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last,
|
||||||
|
let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath)
|
||||||
|
else { return layout }
|
||||||
|
|
||||||
|
let bottomCellFrame = tableView.cellFrame(for: bottomItem)
|
||||||
|
layout.pinnedItem = PinnedItem(id: bottomItem.id, position: .bottom, frame: bottomCellFrame)
|
||||||
|
layout.isBottomVisible = bottomItem == snapshot.itemIdentifiers.last
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the additional padding added to the top of the table (via a header)
|
||||||
|
/// in order to fill the timeline from the bottom of the view upwards.
|
||||||
|
private func updateTopPadding() {
|
||||||
|
let contentHeight = tableView.contentSize.height - (tableView.tableHeaderView?.frame.height ?? 0)
|
||||||
|
let height = tableView.visibleSize.height - contentHeight
|
||||||
|
|
||||||
|
if height > 0 {
|
||||||
|
let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height))
|
||||||
|
tableView.tableHeaderView = UIView(frame: frame) // Updating an existing view's height doesn't move the cells.
|
||||||
|
} else {
|
||||||
|
tableView.tableHeaderView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not the bottom of the scroll view is visible (with some small tolerance added).
|
||||||
|
private func isAtBottom() -> Bool {
|
||||||
|
tableView.contentOffset.y < (tableView.contentSize.height - tableView.visibleSize.height - 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls to the bottom of the timeline.
|
||||||
|
private func scrollToBottom(animated: Bool) {
|
||||||
|
guard let lastItem = timelineItems.last,
|
||||||
|
let lastIndexPath = dataSource?.indexPath(for: lastItem)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores the position of the timeline using the supplied item and snapshot.
|
||||||
|
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>) {
|
||||||
|
guard let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }),
|
||||||
|
let indexPath = dataSource?.indexPath(for: item)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
// Scroll the item into view.
|
||||||
|
tableView.scrollToRow(at: indexPath, at: pinnedItem.position, animated: false)
|
||||||
|
|
||||||
|
guard let oldFrame = pinnedItem.frame, let newFrame = tableView.cellFrame(for: item) else { return }
|
||||||
|
|
||||||
|
// Remove any unwanted offset that was added by scrollToRow.
|
||||||
|
let deltaY = newFrame.maxY - oldFrame.maxY
|
||||||
|
if deltaY != 0 {
|
||||||
|
tableView.contentOffset.y += deltaY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether or a backwards pagination is needed and requests one if so.
|
||||||
|
///
|
||||||
|
/// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests.
|
||||||
|
private func paginateBackwardsIfNeeded() {
|
||||||
|
guard !isBackPaginating,
|
||||||
|
!hasPendingUpdates,
|
||||||
|
tableView.contentOffset.y < tableView.visibleSize.height * 2.0
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
coordinator.send(viewAction: .paginateBackwards)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the opacity that the supplied timeline item's cell should be.
|
||||||
|
private func opacity(for item: RoomTimelineViewProvider) -> CGFloat {
|
||||||
|
guard case let .reply(selectedItemID, _) = composerMode else { return 1.0 }
|
||||||
|
return selectedItemID == item.id ? 1.0 : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate
|
||||||
|
|
||||||
|
extension TimelineTableViewController: UITableViewDelegate {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let isAtBottom = isAtBottom()
|
||||||
|
|
||||||
|
// Dispatches fix runtime warnings about making changes during a view update.
|
||||||
|
if !scrollToBottomButtonVisible, isAtBottom {
|
||||||
|
DispatchQueue.main.async { self.scrollToBottomButtonVisible = true }
|
||||||
|
} else if scrollToBottomButtonVisible, !isAtBottom {
|
||||||
|
DispatchQueue.main.async { self.scrollToBottomButtonVisible = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
paginateBackwardsPublisher.send(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ScrollViewAdapter Methods
|
||||||
|
|
||||||
|
// Required delegate methods are forwarded to the adapter so others can be implemented.
|
||||||
|
|
||||||
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
scrollAdapter.scrollViewWillBeginDragging(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||||
|
scrollAdapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||||
|
scrollAdapter.scrollViewDidEndScrollingAnimation(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
scrollAdapter.scrollViewDidEndDecelerating(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
|
||||||
|
scrollAdapter.scrollViewDidScrollToTop(scrollView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout Types
|
||||||
|
|
||||||
|
extension TimelineTableViewController {
|
||||||
|
/// The sections of the table view used in the diffable data source.
|
||||||
|
enum TimelineSection { case main }
|
||||||
|
|
||||||
|
/// A description of the timeline's layout.
|
||||||
|
struct LayoutDescriptor {
|
||||||
|
var numberOfItems = 0
|
||||||
|
var pinnedItem: PinnedItem?
|
||||||
|
var isBottomVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An item that should have its position pinned after updates.
|
||||||
|
struct PinnedItem {
|
||||||
|
let id: String
|
||||||
|
let position: UITableView.ScrollPosition
|
||||||
|
let frame: CGRect?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cell Layout
|
||||||
|
|
||||||
|
private extension UITableView {
|
||||||
|
/// Returns the frame of the cell for a particular timeline item.
|
||||||
|
func cellFrame(for item: RoomTimelineViewProvider) -> CGRect? {
|
||||||
|
guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item == item }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return convert(timelineCell.frame, to: superview)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2022 New Vector Ltd
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A table view wrapper that displays the timeline of a room.
|
||||||
|
struct TimelineView: UIViewControllerRepresentable {
|
||||||
|
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
|
||||||
|
@Environment(\.timelineStyle) private var timelineStyle
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> TimelineTableViewController {
|
||||||
|
let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
|
||||||
|
timelineStyle: timelineStyle,
|
||||||
|
scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible,
|
||||||
|
scrollToBottomPublisher: viewModelContext.viewState.scrollToBottomPublisher)
|
||||||
|
viewModelContext.send(viewAction: .paginateBackwards)
|
||||||
|
return tableViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) {
|
||||||
|
context.coordinator.update(tableViewController: uiViewController, timelineStyle: timelineStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(viewModelContext: viewModelContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class Coordinator {
|
||||||
|
let context: RoomScreenViewModel.Context
|
||||||
|
|
||||||
|
init(viewModelContext: RoomScreenViewModel.Context) {
|
||||||
|
context = viewModelContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the specified table view's properties from the current view state.
|
||||||
|
func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) {
|
||||||
|
if tableViewController.timelineStyle != timelineStyle {
|
||||||
|
tableViewController.timelineStyle = timelineStyle
|
||||||
|
}
|
||||||
|
if tableViewController.timelineItems != context.viewState.items {
|
||||||
|
tableViewController.timelineItems = context.viewState.items
|
||||||
|
}
|
||||||
|
if tableViewController.isBackPaginating != context.viewState.isBackPaginating {
|
||||||
|
tableViewController.isBackPaginating = context.viewState.isBackPaginating
|
||||||
|
}
|
||||||
|
if tableViewController.composerMode != context.viewState.composerMode {
|
||||||
|
tableViewController.composerMode = context.viewState.composerMode
|
||||||
|
}
|
||||||
|
if tableViewController.displayReactionsMenuForItemId != context.viewState.displayReactionsMenuForItemId {
|
||||||
|
tableViewController.displayReactionsMenuForItemId = context.viewState.displayReactionsMenuForItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doesn't have an equatable conformance :(
|
||||||
|
tableViewController.contextMenuBuilder = context.viewState.contextMenuBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(viewAction: RoomScreenViewAction) {
|
||||||
|
context.send(viewAction: viewAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
struct TimelineTableView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||||
|
timelineViewFactory: RoomTimelineViewFactory(),
|
||||||
|
mediaProvider: MockMediaProvider(),
|
||||||
|
roomName: "Preview room")
|
||||||
|
|
||||||
|
NavigationView {
|
||||||
|
RoomScreen(context: viewModel.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user