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:
Mauro
2023-07-25 14:20:13 +02:00
committed by GitHub
parent 43dafda3ef
commit 7cf32ba37c
17 changed files with 284 additions and 480 deletions
+11 -1
View File
@@ -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 {
@@ -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
}
}
@@ -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)