mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
SwiftUI timeline refactor + TimelineViewState decoupling (#1392)
* swiftUI conversion + scroll to bottom * remove unused * back pagination implemented, however when a lot of elements are in the scroll the perfomance diminishes not sure what may be causing it * scroll adapter solution * way better * works but height animation in cells are broken * code improvement * everything implemented * rebase fix * fix * doc * fix test compilation * code improvement and animation improvement * code improvement * code improvements * tests updated. * pr comments * better identifiers * fix * some PR comments * pr comments
This commit is contained in:
@@ -86,7 +86,6 @@
|
||||
206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; };
|
||||
208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; };
|
||||
2185C1F6724C78FFF355D6FA /* WelcomeScreenScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */; };
|
||||
2198B4458AFF69102BBCC370 /* RoomTimelineItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */; };
|
||||
21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = C733D11B421CFE3A657EF230 /* test_image.png */; };
|
||||
22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; };
|
||||
2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */; };
|
||||
@@ -172,6 +171,7 @@
|
||||
40B79D20A873620F7F128A2C /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; };
|
||||
414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; };
|
||||
4166A7DD2A4E2EFF0EB9369B /* FormRowLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */; };
|
||||
41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */; };
|
||||
41DFDD212D1BE57CA50D783B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 0DD568A494247444A4B56031 /* Kingfisher */; };
|
||||
41F553349AF44567184822D8 /* APNSPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D670124FC3E84F23A62CCF /* APNSPayload.swift */; };
|
||||
4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */; };
|
||||
@@ -296,7 +296,6 @@
|
||||
6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D698BFD68B061350553930 /* WaitingDialog.swift */; };
|
||||
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; };
|
||||
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */; };
|
||||
702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */; };
|
||||
70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; };
|
||||
70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; };
|
||||
706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; };
|
||||
@@ -1372,6 +1371,7 @@
|
||||
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
|
||||
D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = "<group>"; };
|
||||
D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = "<group>"; };
|
||||
D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRowLabelStyle.swift; sourceTree = "<group>"; };
|
||||
D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = "<group>"; };
|
||||
D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -1472,10 +1472,8 @@
|
||||
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = "<group>"; };
|
||||
F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenUITests.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>"; };
|
||||
F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewModel.swift; sourceTree = "<group>"; };
|
||||
FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = "<group>"; };
|
||||
FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -2590,7 +2588,6 @@
|
||||
4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */,
|
||||
A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */,
|
||||
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
||||
F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */,
|
||||
874A1842477895F199567BD7 /* TimelineView.swift */,
|
||||
1D8572B713A11CFDBF009B2F /* Replies */,
|
||||
A312471EA62EFB0FD94E60DC /* Style */,
|
||||
@@ -2892,7 +2889,7 @@
|
||||
7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */,
|
||||
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */,
|
||||
90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */,
|
||||
FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */,
|
||||
D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */,
|
||||
75D1D02F7F3AC1122FCFB4F3 /* Items */,
|
||||
);
|
||||
path = TimelineItems;
|
||||
@@ -3627,7 +3624,7 @@
|
||||
path = Timeline;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
"TEMP_62CE5EF1-8768-4933-9547-E5F60DAC11F4" /* element-x-ios */ = {
|
||||
"TEMP_43147A32-A06C-4EEF-8C45-FDEA43A25C1E" /* element-x-ios */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
41553551C55AD59885840F0E /* secrets.xcconfig */,
|
||||
@@ -4532,7 +4529,7 @@
|
||||
878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */,
|
||||
1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */,
|
||||
AD55E245FE686D7DB4C86406 /* RoomTimelineItemView.swift in Sources */,
|
||||
2198B4458AFF69102BBCC370 /* RoomTimelineItemViewModel.swift in Sources */,
|
||||
41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */,
|
||||
9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */,
|
||||
77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */,
|
||||
B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */,
|
||||
@@ -4631,7 +4628,6 @@
|
||||
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */,
|
||||
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
|
||||
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
|
||||
702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */,
|
||||
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
|
||||
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,
|
||||
A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */,
|
||||
|
||||
@@ -228,8 +228,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "245d527002f29c5a616c5b7131f6a50ac7f41cbf",
|
||||
"version" : "0.8.6"
|
||||
"revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -29,7 +29,9 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate {
|
||||
scrollView?.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var shouldScrollToTopClosure: ((UIScrollView) -> Bool)?
|
||||
|
||||
private let didScrollSubject = PassthroughSubject<Void, Never>()
|
||||
var didScroll: AnyPublisher<Void, Never> {
|
||||
didScrollSubject.eraseToAnyPublisher()
|
||||
@@ -73,6 +75,14 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate {
|
||||
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
|
||||
updateDidScroll(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
guard let shouldScrollToTopClosure else {
|
||||
// Default behaviour
|
||||
return true
|
||||
}
|
||||
return shouldScrollToTopClosure(scrollView)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ enum RoomScreenComposerMode: Equatable {
|
||||
|
||||
enum RoomScreenViewAction {
|
||||
case displayRoomDetails
|
||||
case paginateBackwards
|
||||
case itemAppeared(itemID: TimelineItemIdentifier)
|
||||
case itemDisappeared(itemID: TimelineItemIdentifier)
|
||||
case itemTapped(itemID: TimelineItemIdentifier)
|
||||
@@ -86,18 +85,14 @@ struct RoomScreenViewState: BindableState {
|
||||
var roomId: String
|
||||
var roomTitle = ""
|
||||
var roomAvatarURL: URL?
|
||||
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
|
||||
var members: [String: RoomMemberState] = [:]
|
||||
var canBackPaginate = true
|
||||
var isBackPaginating = false
|
||||
var showLoading = false
|
||||
var timelineStyle: TimelineStyle
|
||||
var readReceiptsEnabled: Bool
|
||||
var isEncryptedOneToOneRoom = false
|
||||
|
||||
var timelineViewState = TimelineViewState() // check the doc before changing this
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
|
||||
/// A closure providing the actions to show when long pressing on an item in the timeline.
|
||||
@@ -106,14 +101,6 @@ struct RoomScreenViewState: BindableState {
|
||||
var sendButtonDisabled: Bool {
|
||||
bindings.composerText.count == 0
|
||||
}
|
||||
|
||||
var timelineIDs: [String] {
|
||||
itemsDictionary.keys.elements
|
||||
}
|
||||
|
||||
var itemViewModels: [RoomTimelineItemViewModel] {
|
||||
itemsDictionary.values.elements
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomScreenViewStateBindings {
|
||||
@@ -184,3 +171,21 @@ struct RoomMemberState {
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
}
|
||||
|
||||
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
|
||||
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
|
||||
struct TimelineViewState {
|
||||
var canBackPaginate = true
|
||||
var isBackPaginating = false
|
||||
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||
|
||||
var timelineIDs: [String] {
|
||||
itemsDictionary.keys.elements
|
||||
}
|
||||
|
||||
var itemViewStates: [RoomTimelineItemViewState] {
|
||||
itemsDictionary.values.elements
|
||||
}
|
||||
|
||||
var paginateAction: (() -> Void)?
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
private var canCurrentUserRedact = false
|
||||
|
||||
private var paginateBackwardsTask: Task<Void, Never>?
|
||||
|
||||
init(timelineController: RoomTimelineControllerProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
roomProxy: RoomProxyProtocol,
|
||||
@@ -60,6 +62,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])),
|
||||
imageProvider: mediaProvider)
|
||||
|
||||
state.timelineViewState.paginateAction = { [weak self] in
|
||||
self?.paginateBackwards()
|
||||
}
|
||||
|
||||
setupSubscriptions()
|
||||
|
||||
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
|
||||
@@ -84,8 +90,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
switch viewAction {
|
||||
case .displayRoomDetails:
|
||||
callback?(.displayRoomDetails)
|
||||
case .paginateBackwards:
|
||||
Task { await paginateBackwards() }
|
||||
case .itemAppeared(let id):
|
||||
Task { await timelineController.processItemAppearance(id) }
|
||||
case .itemDisappeared(let id):
|
||||
@@ -151,12 +155,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
case .updatedTimelineItems:
|
||||
self.buildTimelineViews()
|
||||
case .canBackPaginate(let canBackPaginate):
|
||||
if self.state.canBackPaginate != canBackPaginate {
|
||||
self.state.canBackPaginate = canBackPaginate
|
||||
if self.state.timelineViewState.canBackPaginate != canBackPaginate {
|
||||
self.state.timelineViewState.canBackPaginate = canBackPaginate
|
||||
}
|
||||
case .isBackPaginating(let isBackPaginating):
|
||||
if self.state.isBackPaginating != isBackPaginating {
|
||||
self.state.isBackPaginating = isBackPaginating
|
||||
if self.state.timelineViewState.isBackPaginating != isBackPaginating {
|
||||
self.state.timelineViewState.isBackPaginating = isBackPaginating
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,12 +219,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func paginateBackwards() async {
|
||||
switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) {
|
||||
case .failure:
|
||||
displayError(.toast(L10n.errorFailedLoadingMessages))
|
||||
default:
|
||||
break
|
||||
private func paginateBackwards() {
|
||||
guard paginateBackwardsTask == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
paginateBackwardsTask = Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) {
|
||||
case .failure:
|
||||
displayError(.toast(L10n.errorFailedLoadingMessages))
|
||||
default:
|
||||
break
|
||||
}
|
||||
paginateBackwardsTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +260,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
|
||||
private func buildTimelineViews() {
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||
|
||||
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
|
||||
canGroupItem(timelineItem: current, with: next)
|
||||
@@ -259,36 +274,26 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
if itemGroup.count == 1 {
|
||||
if let firstItem = itemGroup.first {
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: firstItem, groupStyle: .single),
|
||||
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: firstItem, groupStyle: .single),
|
||||
forKey: firstItem.id.timelineID)
|
||||
}
|
||||
} else {
|
||||
for (index, item) in itemGroup.enumerated() {
|
||||
if index == 0 {
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .first),
|
||||
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .first),
|
||||
forKey: item.id.timelineID)
|
||||
} else if index == itemGroup.count - 1 {
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .last),
|
||||
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .last),
|
||||
forKey: item.id.timelineID)
|
||||
} else {
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .middle),
|
||||
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .middle),
|
||||
forKey: item.id.timelineID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.itemsDictionary = timelineItemsDictionary
|
||||
}
|
||||
|
||||
private func updateViewModel(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewModel {
|
||||
if let timelineItemViewModel = state.itemsDictionary[item.id.timelineID] {
|
||||
timelineItemViewModel.groupStyle = groupStyle
|
||||
timelineItemViewModel.type = .init(item: item)
|
||||
return timelineItemViewModel
|
||||
} else {
|
||||
return RoomTimelineItemViewModel(item: item, groupStyle: groupStyle)
|
||||
}
|
||||
state.timelineViewState.itemsDictionary = timelineItemsDictionary
|
||||
}
|
||||
|
||||
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
|
||||
@@ -666,7 +671,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
// MARK: - Reactions
|
||||
|
||||
private func showEmojiPicker(for itemID: TimelineItemIdentifier) {
|
||||
guard let item = state.itemsDictionary[itemID.timelineID], item.isReactable else { return }
|
||||
guard let item = state.timelineViewState.itemsDictionary[itemID.timelineID], item.isReactable else { return }
|
||||
callback?(.displayEmojiPicker(itemID: itemID))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RoomScreen: View {
|
||||
|
||||
var body: some View {
|
||||
timeline
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea()) // Kills the toolbar translucency.
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
HStack(alignment: .bottom, spacing: attachmentButtonPadding) {
|
||||
RoomAttachmentPicker(context: context)
|
||||
@@ -36,6 +36,7 @@ struct RoomScreen: View {
|
||||
.padding(.trailing, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom)
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
@@ -80,14 +81,13 @@ struct RoomScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var timeline: some View {
|
||||
TimelineView()
|
||||
TimelineView(viewState: context.viewState.timelineViewState)
|
||||
.id(context.viewState.roomId)
|
||||
.environmentObject(context)
|
||||
.environment(\.timelineStyle, context.viewState.timelineStyle)
|
||||
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
|
||||
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
|
||||
}
|
||||
|
||||
private var messageComposer: some View {
|
||||
@@ -108,27 +108,6 @@ struct RoomScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var scrollToBottomButton: some View {
|
||||
Button { context.viewState.scrollToBottomPublisher.send(()) } label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.compound.bodyLG)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.padding(13)
|
||||
.offset(y: 1)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.compound.iconOnSolidPrimary)
|
||||
// Intentionally using system primary colour to get white/black.
|
||||
.shadow(color: .primary.opacity(0.33), radius: 2.0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0)
|
||||
.accessibilityHidden(!context.scrollToBottomButtonVisible)
|
||||
.animation(.elementDefault, value: context.scrollToBottomButtonVisible)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingIndicator: some View {
|
||||
if context.viewState.showLoading {
|
||||
|
||||
@@ -328,8 +328,8 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
static var mockTimeline: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(viewModel.state.itemViewModels) { itemViewModel in
|
||||
RoomTimelineItemView(viewModel: itemViewModel)
|
||||
ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in
|
||||
RoomTimelineItemView(viewState: viewState)
|
||||
.padding(TimelineStyle.bubbles.rowInsets)
|
||||
// Insets added in the table view cells
|
||||
}
|
||||
@@ -341,7 +341,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
|
||||
static var replies: some View {
|
||||
VStack {
|
||||
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
@@ -350,7 +350,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "Short")))), groupStyle: .single))
|
||||
|
||||
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
|
||||
@@ -144,7 +144,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
||||
let item = MockRoomTimelineController().timelineItems[index]
|
||||
RoomTimelineItemView(viewModel: .init(item: item, groupStyle: .single))
|
||||
RoomTimelineItemView(viewState: .init(item: item, groupStyle: .single))
|
||||
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
}()
|
||||
|
||||
static let sendingLast: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.timelineIDs.last ?? UUID().uuidString
|
||||
let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString
|
||||
var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||
result.properties.deliveryStatus = .sending
|
||||
return result
|
||||
@@ -66,7 +66,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
}()
|
||||
|
||||
static let sentLast: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.timelineIDs.last ?? UUID().uuidString
|
||||
let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString
|
||||
let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||
return result
|
||||
}()
|
||||
|
||||
@@ -23,11 +23,15 @@ struct TimelineItemStatusView: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
context.viewState.timelineIDs.last == timelineItem.id.timelineID &&
|
||||
timelineItem.isOutgoing
|
||||
timelineItem.isOutgoing && context.viewState.timelineViewState.timelineIDs.last == timelineItem.id.timelineID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if !timelineItem.properties.orderedReadReceipts.isEmpty, readReceiptsEnabled {
|
||||
readReceipts
|
||||
} else {
|
||||
|
||||
+10
-9
@@ -18,34 +18,35 @@ import SwiftUI
|
||||
|
||||
struct CollapsibleRoomTimelineView: View {
|
||||
private let timelineItem: CollapsibleTimelineItem
|
||||
private let timelineViewModels: [RoomTimelineItemViewModel]
|
||||
private let groupedViewStates: [RoomTimelineItemViewState]
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
init(timelineItem: CollapsibleTimelineItem) {
|
||||
self.timelineItem = timelineItem
|
||||
timelineViewModels = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
|
||||
groupedViewStates = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup(L10n.roomTimelineStateChanges(timelineItem.items.count), isExpanded: $isExpanded) {
|
||||
Group {
|
||||
ForEach(timelineViewModels) { viewModel in
|
||||
RoomTimelineItemView(viewModel: viewModel)
|
||||
ForEach(groupedViewStates) { viewState in
|
||||
RoomTimelineItemView(viewState: viewState)
|
||||
}
|
||||
}.transition(.opacity.animation(.elementDefault))
|
||||
}
|
||||
}
|
||||
.disclosureGroupStyle(CollapsibleRoomTimelineItemDisclosureGroupStyle())
|
||||
.transaction { transaction in
|
||||
transaction.animation = .noAnimation // Fixes weird animations on the disclosure indicator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollapsibleRoomTimelineItemDisclosureGroupStyle: DisclosureGroupStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(spacing: 0.0) {
|
||||
Button { configuration.isExpanded.toggle() } label: {
|
||||
Button {
|
||||
withElementAnimation {
|
||||
configuration.isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .center) {
|
||||
configuration.label
|
||||
Text(Image(systemName: "chevron.forward"))
|
||||
|
||||
@@ -42,8 +42,8 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
||||
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: .init(timelineID: ""),
|
||||
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
||||
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""),
|
||||
timestamp: "",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
@@ -52,8 +52,8 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
||||
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
|
||||
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
||||
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: .init(timelineID: ""),
|
||||
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
||||
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""),
|
||||
timestamp: "",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
|
||||
@@ -1,283 +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
|
||||
|
||||
import OrderedCollections
|
||||
|
||||
/// 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: RoomTimelineItemViewModel?
|
||||
|
||||
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).
|
||||
/// Also this TableViewController uses a **flipped tableview**
|
||||
class TimelineTableViewController: UIViewController {
|
||||
private let coordinator: TimelineView.Coordinator
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
var timelineStyle: TimelineStyle
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>() {
|
||||
didSet {
|
||||
applySnapshot()
|
||||
|
||||
if timelineItemsDictionary.isEmpty {
|
||||
paginateBackwardsPublisher.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode of the message composer. This is used to render selected
|
||||
/// items in the timeline when replying, editing etc.
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
|
||||
/// Whether or not the timeline has more messages to back paginate.
|
||||
var canBackPaginate = true
|
||||
|
||||
/// Whether or not the timeline is waiting for more messages to be added to the top.
|
||||
var isBackPaginating = false {
|
||||
didSet {
|
||||
// Paginate again if the threshold hasn't been satisfied.
|
||||
paginateBackwardsPublisher.send(())
|
||||
}
|
||||
}
|
||||
|
||||
var contextMenuActionProvider: (@MainActor (_ itemID: TimelineItemIdentifier) -> TimelineItemMenuActions?)?
|
||||
|
||||
@Binding private var scrollToBottomButtonVisible: Bool
|
||||
|
||||
private var timelineItemsIDs: [String] {
|
||||
timelineItemsDictionary.keys.elements.reversed()
|
||||
}
|
||||
|
||||
/// The table's diffable data source.
|
||||
private var dataSource: UITableViewDiffableDataSource<TimelineSection, String>?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// 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 view has been shown on screen yet.
|
||||
private var hasAppearedOnce = false
|
||||
/// Whether the scroll and the animations should happen
|
||||
private var shouldAnimate = 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
|
||||
tableView.backgroundColor = UIColor(.compound.bgCanvasDefault)
|
||||
tableView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
||||
view.addSubview(tableView)
|
||||
|
||||
// Prevents XCUITest from invoking the diffable dataSource's cellProvider
|
||||
// for each possible cell, causing layout issues
|
||||
tableView.accessibilityElementsHidden = Tests.shouldDisableTimelineAccessibility
|
||||
|
||||
scrollToBottomPublisher
|
||||
.sink { [weak self] _ in
|
||||
self?.scrollToBottom(animated: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
paginateBackwardsPublisher
|
||||
.collect(.byTime(DispatchQueue.main, 0.1))
|
||||
.sink { [weak self] _ in
|
||||
self?.paginateBackwardsIfNeeded()
|
||||
}
|
||||
.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 }
|
||||
tableView.contentOffset.y = -1
|
||||
hasAppearedOnce = true
|
||||
paginateBackwardsPublisher.send()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.shouldAnimate = true
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
guard tableView.frame.size != view.frame.size else {
|
||||
return
|
||||
}
|
||||
|
||||
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
|
||||
}
|
||||
|
||||
/// Configures a diffable data source for the timeline's table view.
|
||||
private func configureDataSource() {
|
||||
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, id 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 viewModel = timelineItemsDictionary[id]
|
||||
cell.item = viewModel
|
||||
guard let viewModel else {
|
||||
return cell
|
||||
}
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
RoomTimelineItemView(viewModel: viewModel)
|
||||
.id(id)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
|
||||
.onAppear {
|
||||
coordinator.send(viewAction: .itemAppeared(itemID: viewModel.id))
|
||||
}
|
||||
.onDisappear {
|
||||
coordinator.send(viewAction: .itemDisappeared(itemID: viewModel.id))
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
coordinator.send(viewAction: .linkClicked(url: url))
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
.margins(.all, self.timelineStyle.rowInsets)
|
||||
.minSize(height: 1)
|
||||
.background(Color.clear)
|
||||
|
||||
// Flipping the cell can create some issues with cell resizing, so flip the content View
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
||||
return cell
|
||||
}
|
||||
|
||||
dataSource?.defaultRowAnimation = .fade
|
||||
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 }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(timelineItemsIDs)
|
||||
|
||||
let currentSnapshot = dataSource.snapshot()
|
||||
MXLog.verbose("DIFF: \(snapshot.itemIdentifiers.difference(from: currentSnapshot.itemIdentifiers))")
|
||||
|
||||
// We only animate when new items come at the end of the timeline
|
||||
let animated = shouldAnimate &&
|
||||
snapshot.itemIdentifiers.first != currentSnapshot.itemIdentifiers.first
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// Scrolls to the bottom of the timeline.
|
||||
private func scrollToBottom(animated: Bool) {
|
||||
guard !timelineItemsIDs.isEmpty else {
|
||||
return
|
||||
}
|
||||
tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: animated)
|
||||
}
|
||||
|
||||
/// Scrolls to the top of the timeline.
|
||||
private func scrollToTop(animated: Bool) {
|
||||
guard !timelineItemsIDs.isEmpty else {
|
||||
return
|
||||
}
|
||||
tableView.scrollToRow(at: IndexPath(item: timelineItemsIDs.count - 1, section: 0), at: .bottom, animated: animated)
|
||||
}
|
||||
|
||||
/// Checks whether or a backwards pagination is needed and requests one if so.
|
||||
///
|
||||
/// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests.
|
||||
private func paginateBackwardsIfNeeded() {
|
||||
guard canBackPaginate,
|
||||
!isBackPaginating,
|
||||
tableView.contentOffset.y > tableView.contentSize.height - tableView.visibleSize.height * 2.0
|
||||
else { return }
|
||||
|
||||
coordinator.send(viewAction: .paginateBackwards)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension TimelineTableViewController: UITableViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
paginateBackwardsPublisher.send(())
|
||||
|
||||
// Dispatch to fix runtime warning about making changes during a view update.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let scrollToBottomButtonVisible = scrollView.contentOffset.y > 0
|
||||
|
||||
// Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling.
|
||||
if self.scrollToBottomButtonVisible != scrollToBottomButtonVisible {
|
||||
self.scrollToBottomButtonVisible = scrollToBottomButtonVisible
|
||||
}
|
||||
}
|
||||
|
||||
// We never want the table view to be fully at the bottom to allow the status bar tap to work properly
|
||||
if scrollView.contentOffset.y == 0 {
|
||||
scrollView.contentOffset.y = -1
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
scrollToTop(animated: true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Types
|
||||
|
||||
extension TimelineTableViewController {
|
||||
/// The sections of the table view used in the diffable data source.
|
||||
enum TimelineSection {
|
||||
case main
|
||||
}
|
||||
}
|
||||
@@ -14,69 +14,147 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// A table view wrapper that displays the timeline of a room.
|
||||
struct TimelineView: UIViewControllerRepresentable {
|
||||
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
|
||||
import SwiftUIIntrospect
|
||||
|
||||
struct TimelineView: View {
|
||||
let viewState: TimelineViewState
|
||||
@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)
|
||||
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
|
||||
|
||||
if viewModelContext.viewState.itemViewModels.isEmpty {
|
||||
viewModelContext.send(viewAction: .paginateBackwards)
|
||||
|
||||
private let bottomID = "RoomTimelineBottomPinIdentifier"
|
||||
private let topID = "RoomTimelineTopPinIdentifier"
|
||||
|
||||
@State private var scrollViewAdapter = ScrollViewAdapter()
|
||||
@State private var paginateBackwardsPublisher = PassthroughSubject<Void, Never>()
|
||||
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||
@State private var scrollToBottomButtonVisible = false
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
bottomPin
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(viewState.itemViewStates.reversed()) { viewState in
|
||||
RoomTimelineItemView(viewState: viewState)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(timelineStyle.rowInsets)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
|
||||
topPin
|
||||
}
|
||||
.introspect(.scrollView, on: .iOS(.v16)) { uiScrollView in
|
||||
guard uiScrollView != scrollViewAdapter.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
scrollViewAdapter.scrollView = uiScrollView
|
||||
scrollViewAdapter.shouldScrollToTopClosure = { _ in
|
||||
withElementAnimation {
|
||||
scrollView.scrollTo(topID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Allows the scroll to top to work properly
|
||||
uiScrollView.contentOffset.y -= 1
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
.onReceive(scrollToBottomPublisher) { _ in
|
||||
withElementAnimation {
|
||||
scrollView.scrollTo(bottomID)
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
.overlay(scrollToBottomButton, alignment: .bottomTrailing)
|
||||
.animation(.elementDefault, value: viewState.itemViewStates)
|
||||
.onReceive(scrollViewAdapter.didScroll) { _ in
|
||||
guard let scrollView = scrollViewAdapter.scrollView else {
|
||||
return
|
||||
}
|
||||
let offset = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||
let scrollToBottomButtonVisibleValue = offset > 0
|
||||
if scrollToBottomButtonVisibleValue != scrollToBottomButtonVisible {
|
||||
scrollToBottomButtonVisible = scrollToBottomButtonVisibleValue
|
||||
}
|
||||
paginateBackwardsPublisher.send()
|
||||
|
||||
// Allows the scroll to top to work properly
|
||||
if offset == 0 {
|
||||
scrollView.contentOffset.y -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.timelineItemsDictionary != context.viewState.itemsDictionary {
|
||||
tableViewController.timelineItemsDictionary = context.viewState.itemsDictionary
|
||||
}
|
||||
if tableViewController.canBackPaginate != context.viewState.canBackPaginate {
|
||||
tableViewController.canBackPaginate = context.viewState.canBackPaginate
|
||||
}
|
||||
if tableViewController.isBackPaginating != context.viewState.isBackPaginating {
|
||||
tableViewController.isBackPaginating = context.viewState.isBackPaginating
|
||||
}
|
||||
if tableViewController.composerMode != context.viewState.composerMode {
|
||||
tableViewController.composerMode = context.viewState.composerMode
|
||||
}
|
||||
|
||||
// Doesn't have an equatable conformance :(
|
||||
tableViewController.contextMenuActionProvider = context.viewState.timelineItemMenuActionProvider
|
||||
.onReceive(paginateBackwardsPublisher.collect(.byTime(DispatchQueue.main, 0.1))) { _ in
|
||||
paginateBackwardsIfNeeded()
|
||||
}
|
||||
|
||||
func send(viewAction: RoomScreenViewAction) {
|
||||
context.send(viewAction: viewAction)
|
||||
.onAppear {
|
||||
paginateBackwardsPublisher.send()
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to mark the top of the scroll view and easily scroll to it
|
||||
private var topPin: some View {
|
||||
Divider()
|
||||
.id(topID)
|
||||
.hidden()
|
||||
.frame(height: 0)
|
||||
}
|
||||
|
||||
/// Used to mark the bottom of the scroll view and easily scroll to it
|
||||
private var bottomPin: some View {
|
||||
Divider()
|
||||
.id(bottomID)
|
||||
.hidden()
|
||||
.frame(height: 0)
|
||||
}
|
||||
|
||||
private var scrollToBottomButton: some View {
|
||||
Button {
|
||||
scrollToBottomPublisher.send()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.compound.bodyLG)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.padding(13)
|
||||
.offset(y: 1)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.compound.iconOnSolidPrimary)
|
||||
// Intentionally using system primary colour to get white/black.
|
||||
.shadow(color: .primary.opacity(0.33), radius: 2.0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.opacity(scrollToBottomButtonVisible ? 1.0 : 0.0)
|
||||
.accessibilityHidden(!scrollToBottomButtonVisible)
|
||||
.animation(.elementDefault, value: scrollToBottomButtonVisible)
|
||||
}
|
||||
|
||||
private func paginateBackwardsIfNeeded() {
|
||||
guard let paginateAction = viewState.paginateAction,
|
||||
let scrollView = scrollViewAdapter.scrollView,
|
||||
viewState.canBackPaginate,
|
||||
!viewState.isBackPaginating else {
|
||||
return
|
||||
}
|
||||
|
||||
let visibleHeight = scrollView.visibleSize.height
|
||||
let contentHeight = scrollView.contentSize.height
|
||||
let offset = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||
let threshold = contentHeight - visibleHeight * 2
|
||||
|
||||
guard offset > threshold else {
|
||||
return
|
||||
}
|
||||
|
||||
paginateAction()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -16,17 +16,27 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RoomTimelineItemView: View {
|
||||
@ObservedObject var viewModel: RoomTimelineItemViewModel
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
let viewState: RoomTimelineItemViewState
|
||||
|
||||
var body: some View {
|
||||
timelineView
|
||||
.environment(\.timelineGroupStyle, viewModel.groupStyle)
|
||||
.animation(.elementDefault, value: viewModel.type)
|
||||
.animation(.elementDefault, value: viewModel.groupStyle)
|
||||
.environmentObject(context)
|
||||
.environment(\.timelineGroupStyle, viewState.groupStyle)
|
||||
.onAppear {
|
||||
context.send(viewAction: .itemAppeared(itemID: viewState.identifier))
|
||||
}
|
||||
.onDisappear {
|
||||
context.send(viewAction: .itemDisappeared(itemID: viewState.identifier))
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
context.send(viewAction: .linkClicked(url: url))
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder private var timelineView: some View {
|
||||
switch viewModel.type {
|
||||
switch viewState.type {
|
||||
case .text(let item):
|
||||
TextRoomTimelineView(timelineItem: item)
|
||||
case .separator(let item):
|
||||
@@ -69,6 +79,6 @@ struct RoomTimelineItemView: View {
|
||||
}
|
||||
|
||||
var timelineGroupStyle: TimelineGroupStyle {
|
||||
viewModel.groupStyle
|
||||
viewState.groupStyle
|
||||
}
|
||||
}
|
||||
|
||||
+14
-15
@@ -16,25 +16,18 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
final class RoomTimelineItemViewModel: Identifiable, Equatable, ObservableObject {
|
||||
static func == (lhs: RoomTimelineItemViewModel, rhs: RoomTimelineItemViewModel) -> Bool {
|
||||
lhs.type == rhs.type && lhs.groupStyle == rhs.groupStyle
|
||||
}
|
||||
struct RoomTimelineItemViewState: Identifiable, Equatable {
|
||||
let type: RoomTimelineItemType
|
||||
let groupStyle: TimelineGroupStyle
|
||||
|
||||
@Published var type: RoomTimelineItemType
|
||||
@Published var groupStyle: TimelineGroupStyle
|
||||
|
||||
var id: TimelineItemIdentifier {
|
||||
/// Contains all the identification info of the item, `timelineID`, `eventID` and `transactionID`
|
||||
var identifier: TimelineItemIdentifier {
|
||||
type.id
|
||||
}
|
||||
|
||||
convenience init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
|
||||
self.init(type: .init(item: item), groupStyle: groupStyle)
|
||||
}
|
||||
|
||||
init(type: RoomTimelineItemType, groupStyle: TimelineGroupStyle) {
|
||||
self.type = type
|
||||
self.groupStyle = groupStyle
|
||||
/// The `timelineID` of the item, used for the timeline view level identification, do not use for any business logic use `identifier` instead
|
||||
var id: String {
|
||||
identifier.timelineID
|
||||
}
|
||||
|
||||
var isReactable: Bool {
|
||||
@@ -42,6 +35,12 @@ final class RoomTimelineItemViewModel: Identifiable, Equatable, ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomTimelineItemViewState {
|
||||
init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
|
||||
self.init(type: .init(item: item), groupStyle: groupStyle)
|
||||
}
|
||||
}
|
||||
|
||||
enum RoomTimelineItemType: Equatable {
|
||||
case text(TextRoomTimelineItem)
|
||||
case separator(SeparatorRoomTimelineItem)
|
||||
@@ -51,9 +51,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
userIndicatorController: userIndicatorControllerMock)
|
||||
|
||||
// Then the messages should be grouped together.
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
||||
}
|
||||
|
||||
func testMessageGroupingMultipleSenders() {
|
||||
@@ -84,12 +84,12 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
userIndicatorController: userIndicatorControllerMock)
|
||||
|
||||
// Then the messages should be grouped by sender.
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
||||
}
|
||||
|
||||
func testMessageGroupingWithLeadingReactions() {
|
||||
@@ -115,9 +115,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
userIndicatorController: userIndicatorControllerMock)
|
||||
|
||||
// Then the first message should not be grouped but the other two should.
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .single, "When the first message has reactions it should not be grouped.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
||||
}
|
||||
|
||||
func testMessageGroupingWithInnerReactions() {
|
||||
@@ -143,9 +143,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
userIndicatorController: userIndicatorControllerMock)
|
||||
|
||||
// Then the first and second messages should be grouped and the last one should not.
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .last, "When the message has reactions, the group should end here.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
|
||||
}
|
||||
|
||||
func testMessageGroupingWithTrailingReactions() {
|
||||
@@ -171,9 +171,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
userIndicatorController: userIndicatorControllerMock)
|
||||
|
||||
// Then the messages should be grouped together.
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
|
||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
|
||||
}
|
||||
|
||||
func testGoToUserDetailsSuccessNoDelay() async {
|
||||
|
||||
Reference in New Issue
Block a user