Use a UITableView component for the timeline. (#349)

* Use a collection view for the timeline.
* Switch to a table view.
This commit is contained in:
Doug
2022-12-05 15:39:21 +00:00
committed by GitHub
parent 9415bd3a7a
commit 3c893ba342
27 changed files with 583 additions and 463 deletions
@@ -16,6 +16,7 @@
"room_timeline_replying_to" = "Replying to %@";
"room_timeline_editing" = "Editing";
"room_timeline_syncing" = "Syncing";
"session_verification_banner_title" = "Help keep your messages secure";
"session_verification_banner_message" = "Looks like you’re using a new device. Verify its you.";
@@ -38,6 +38,8 @@ extension ElementL10n {
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
/// Plain Timeline
public static let roomTimelineStylePlainLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_plain_long_description")
/// Syncing
public static let roomTimelineSyncing = ElementL10n.tr("Untranslated", "room_timeline_syncing")
/// Would you like to submit a bug report?
public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message")
/// You took a screenshot
@@ -25,30 +25,29 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate {
}
}
var isScrolling = PassthroughSubject<Bool, Never>()
var isScrolling = CurrentValueSubject<Bool, Never>(false)
private func update() {
guard let scrollView else { return }
private func update(_ scrollView: UIScrollView) {
isScrolling.send(scrollView.isDragging || scrollView.isDecelerating)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
update()
update(scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
update()
update(scrollView)
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
update()
update(scrollView)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
update()
update(scrollView)
}
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
update()
update(scrollView)
}
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
import UIKit
enum RoomScreenViewModelAction {
@@ -29,7 +29,7 @@ enum RoomScreenComposerMode: Equatable {
}
enum RoomScreenViewAction {
case loadPreviousPage
case paginateBackwards
case itemAppeared(id: String)
case itemDisappeared(id: String)
case itemTapped(id: String)
@@ -56,12 +56,22 @@ struct RoomScreenViewState: BindableState {
var sendButtonDisabled: Bool {
bindings.composerText.count == 0
}
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
/// Returns the opacity that the supplied timeline item's cell should be.
func opacity(for item: RoomTimelineViewProvider) -> CGFloat {
guard case let .reply(selectedItemID, _) = composerMode else { return 1.0 }
return selectedItemID == item.id ? 1.0 : 0.5
}
}
struct RoomScreenViewStateBindings {
var composerText: String
var composerFocused: Bool
var scrollToBottomButtonVisible = false
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomScreenErrorType>?
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
@@ -21,6 +22,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let backPaginationPageSize: UInt = 20
static let backPaginationIndicatorID = "RoomBackPagination"
}
private let timelineController: RoomTimelineControllerProtocol
@@ -60,8 +62,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem)
case .startedBackPaginating:
self.state.isBackPaginating = true
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Constants.backPaginationIndicatorID,
type: .toast,
title: ElementL10n.roomTimelineSyncing,
persistent: true))
case .finishedBackPaginating:
self.state.isBackPaginating = false
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Constants.backPaginationIndicatorID)
}
}
.store(in: &cancellables)
@@ -86,11 +93,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
override func process(viewAction: RoomScreenViewAction) async {
switch viewAction {
case .loadPreviousPage:
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
default:
#warning("Treat errors")
}
case .paginateBackwards:
await paginateBackwards()
case .itemAppeared(let id):
await timelineController.processItemAppearance(id)
case .itemDisappeared(let id):
@@ -118,6 +122,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// MARK: - Private
private func paginateBackwards() async {
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
default:
#warning("Treat errors")
}
}
private func itemTapped(with itemId: String) async {
state.showLoading = true
@@ -17,51 +17,70 @@
import SwiftUI
struct RoomScreen: View {
@ObservedObject private var settings = ElementSettings.shared
@ObservedObject var context: RoomScreenViewModel.Context
var body: some View {
ZStack {
VStack(spacing: 0.0) {
TimelineView()
.environmentObject(context)
MessageComposer(text: $context.composerText,
focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) {
sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
}
.padding()
}
timeline
.safeAreaInset(edge: .bottom) { messageComposer }
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
RoomHeaderView(context: context)
}
}
.toolbar { toolbar }
.overlay { loadingIndicator }
.alert(item: $context.alertInfo) { $0.alert }
.sheet(item: $context.debugInfo) { DebugScreen(info: $0) }
if context.viewState.showLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(.element.primaryContent)
.padding(16)
.background(Color.element.quinaryContent)
.cornerRadius(8)
}
}
var timeline: some View {
TimelineTableView()
.environmentObject(context)
.timelineStyle(settings.timelineStyle)
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
}
var messageComposer: some View {
MessageComposer(text: $context.composerText,
focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) {
sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
}
.padding()
}
var scrollToBottomButton: some View {
Button { context.viewState.scrollToBottomPublisher.send(()) } label: {
Image(uiImage: Asset.Images.timelineScrollToBottom.image)
.shadow(radius: 2.0)
.padding()
}
.opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0)
.animation(.elementDefault, value: context.scrollToBottomButtonVisible)
}
@ViewBuilder
var loadingIndicator: some View {
if context.viewState.showLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(.element.primaryContent)
.padding(16)
.background(Color.element.quinaryContent)
.cornerRadius(8)
}
}
var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
RoomHeaderView(context: context)
}
}
private func sendMessage() {
guard !context.viewState.sendButtonDisabled else {
return
}
guard !context.viewState.sendButtonDisabled else { return }
context.send(viewAction: .sendMessage)
}
}
@@ -1,210 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct TimelineItemList: View {
@ObservedObject private var settings = ElementSettings.shared
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var viewFrame: CGRect = .zero
@State private var pinnedItem: PinnedItem?
@Binding var visibleEdges: [VerticalEdge]
/// The last known value of the visible edges. This is stored because `visibleEdges`
/// updates at the same time as the `viewFrame` but we need to know the previous
/// value when the keyboard appears to determine whether to scroll to the bottom.
@State private var cachedVisibleEdges: [VerticalEdge] = []
@EnvironmentObject var context: RoomScreenViewModel.Context
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
var body: some View {
ScrollViewReader { scrollView in
TimelineScrollView(visibleEdges: $visibleEdges) {
// The scroll view already contains a VStack so simply provide the content to fill it.
ProgressView()
.frame(maxWidth: .infinity)
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in
item
.contextMenu {
context.viewState.contextMenuBuilder?(item.id)
.id(item.id)
}
.opacity(opacityForItem(item))
.padding(settings.timelineStyle.rowInsets)
.onAppear {
context.send(viewAction: .itemAppeared(id: item.id))
}
.onDisappear {
context.send(viewAction: .itemDisappeared(id: item.id))
}
.environment(\.openURL, OpenURLAction { url in
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture {
context.send(viewAction: .itemTapped(id: item.id))
}
}
}
.onChange(of: visibleEdges) { edges in
cachedVisibleEdges = edges
// Paginate when the top becomes visible
guard edges.contains(.top) else { return }
requestBackPagination()
}
.onChange(of: context.viewState.isBackPaginating) { isBackPaginating in
guard !isBackPaginating else { return }
// Repeat the pagination if the top edge is still visible.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
guard visibleEdges.contains(.top) else { return }
requestBackPagination()
}
}
.onChange(of: pinnedItem) { item in
guard let item else { return }
if item.animated {
withAnimation(Animation.elementDefault) {
scrollView.scrollTo(item.id, anchor: item.anchor)
}
} else {
scrollView.scrollTo(item.id, anchor: item.anchor)
}
pinnedItem = nil
}
}
.scrollDismissesKeyboard(.immediately)
.background(ViewFrameReader(frame: $viewFrame))
.timelineStyle(settings.timelineStyle)
.onAppear {
timelineItems = context.viewState.items
}
.onReceive(scrollToBottomPublisher) {
scrollToBottom(animated: true)
}
.onChange(of: context.viewState.items) { items in
guard
!context.viewState.items.isEmpty,
context.viewState.items.count != timelineItems.count
else {
// Update the items, but don't worry about scrolling if the count is unchanged.
timelineItems = items
return
}
// Pin to the bottom if empty
if timelineItems.isEmpty {
if let lastItem = context.viewState.items.last {
let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false)
timelineItems = context.viewState.items
self.pinnedItem = pinnedItem
}
return
}
// Pin to the new bottom if visible
if visibleEdges.contains(.bottom), let newLastItem = context.viewState.items.last {
let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false)
timelineItems = context.viewState.items
self.pinnedItem = pinnedItem
return
}
// Pin to the old topmost visible
if visibleEdges.contains(.top), let currentFirstItem = timelineItems.first {
let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false)
timelineItems = context.viewState.items
self.pinnedItem = pinnedItem
return
}
// Otherwise just update the items
timelineItems = context.viewState.items
}
.onChange(of: viewFrame) { _ in
// Use the cached version as visibleEdges will already have changed
// (but its onChange handler is yet to be called - possible race condition?)
guard cachedVisibleEdges.contains(.bottom) else { return }
// Pin the timeline to the bottom if was there on the frame change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollToBottom(animated: true)
}
}
}
// MARK: - Private
private func scrollToBottom(animated: Bool = false) {
if let lastItem = timelineItems.last {
pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: animated)
}
}
private func requestBackPagination() {
guard !context.viewState.isBackPaginating else {
return
}
context.send(viewAction: .loadPreviousPage)
}
private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double {
guard case let .reply(selectedItemId, _) = context.viewState.composerMode else {
return 1.0
}
return selectedItemId == item.id ? 1.0 : 0.5
}
private var isRunningPreviews: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#else
return false
#endif
}
}
private struct PinnedItem: Equatable {
let id: String
let anchor: UnitPoint
let animated: Bool
}
struct TimelineItemList_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: nil)
TimelineItemList(visibleEdges: .constant([]), scrollToBottomPublisher: PassthroughSubject())
.environmentObject(viewModel.context)
}
}
@@ -1,76 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct VisibleEdgesKey: PreferenceKey {
static var defaultValue: [VerticalEdge] = []
static func reduce(value: inout [VerticalEdge], nextValue: () -> [VerticalEdge]) {
value = nextValue()
}
}
/// A SwiftUI scroll view with the following customisations for a room timeline
/// - The content is laid out starting at the bottom.
/// - Top and bottom edge visibility detection for triggering other behaviours.
struct TimelineScrollView<Content: View>: View {
@Binding var visibleEdges: [VerticalEdge]
@ViewBuilder var content: () -> Content
/// A small threshold added to the edge detection to allow a bit of leniency.
private let edgeDetectionThreshold: CGFloat = 15
var body: some View {
GeometryReader { scrollViewGeometry in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Spacer()
content()
}
.frame(minHeight: scrollViewGeometry.size.height)
.background {
GeometryReader { contentGeometry in
Color.clear
.preference(key: VisibleEdgesKey.self,
value: visibleEdges(of: contentGeometry, in: scrollViewGeometry))
}
.onPreferenceChange(VisibleEdgesKey.self) {
visibleEdges = $0
}
}
}
}
}
func visibleEdges(of contentGeometry: GeometryProxy, in scrollViewGeometry: GeometryProxy) -> [VerticalEdge] {
let frame = contentGeometry.frame(in: .global)
let isTopVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.minY + edgeDetectionThreshold))
let isBottomVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.maxY - edgeDetectionThreshold))
switch (isTopVisible, isBottomVisible) {
case (false, false):
return []
case (true, false):
return [.top]
case (false, true):
return [.bottom]
case (true, true):
return [.top, .bottom]
}
}
}
@@ -0,0 +1,456 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
/// A table view cell that displays a timeline item in a room. The cell is intended
/// to be configured to display a SwiftUI view and not use any UIKit.
class TimelineItemCell: UITableViewCell {
static let reuseIdentifier = "TimelineItemCell"
var item: RoomTimelineViewProvider?
override func prepareForReuse() {
item = nil
}
}
/// A table view wrapper that displays the timeline of a room.
struct TimelineTableView: UIViewRepresentable {
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
@Environment(\.timelineStyle) private var timelineStyle
func makeUIView(context: Context) -> UITableView {
let tableView = UITableView(frame: .zero, style: .plain)
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
tableView.separatorStyle = .none
tableView.allowsSelection = false
tableView.keyboardDismissMode = .onDrag
context.coordinator.tableView = tableView
viewModelContext.send(viewAction: .paginateBackwards)
return tableView
}
func updateUIView(_ uiView: UITableView, context: Context) {
context.coordinator.update()
if context.coordinator.timelineStyle != timelineStyle {
context.coordinator.timelineStyle = timelineStyle
}
}
func makeCoordinator() -> Coordinator {
Coordinator(viewModelContext: viewModelContext)
}
// MARK: - Coordinator
@MainActor
class Coordinator: NSObject {
let viewModelContext: RoomScreenViewModel.Context
var tableView: UITableView? {
didSet {
registerFrameObserver()
configureDataSource()
}
}
var timelineStyle: TimelineStyle = .bubbles
var timelineItems: [RoomTimelineViewProvider] = [] {
didSet {
guard !scrollAdapter.isScrolling.value else {
// Delay updating until scrolling has stopped as programatic
// changes to the scroll position kills any inertia.
hasPendingUpdates = true
return
}
applySnapshot()
}
}
/// The mode of the message composer. This is used to render selected
/// items in the timeline when replying, editing etc.
var composerMode: RoomScreenComposerMode = .default {
didSet {
// Reload the visible items in order to update their opacity.
// Applying a snapshot won't work in this instance as the items don't change.
reloadVisibleItems()
}
}
/// Whether or not the timeline is waiting for more messages to be added to the top.
var isBackPaginating = false {
didSet {
// Paginate again if the threshold hasn't been satisfied.
paginateBackwardsPublisher.send(())
}
}
/// The table's diffable data source.
private var dataSource: UITableViewDiffableDataSource<TimelineSection, RoomTimelineViewProvider>?
private var cancellables: Set<AnyCancellable> = []
/// The scroll view adapter used to detect whether scrolling is in progress.
private let scrollAdapter = ScrollViewAdapter()
/// A publisher used to throttle back pagination requests.
///
/// Our view actions get wrapped in a `Task` so it is possible that a second call in
/// quick succession can execute before ``isBackPaginating`` becomes `true`.
private let paginateBackwardsPublisher = PassthroughSubject<Void, Never>()
/// Whether or not the ``timelineItems`` value should be applied when scrolling stops.
private var hasPendingUpdates = false
/// The observation token used to handle frame changes.
private var frameObserverToken: NSKeyValueObservation?
/// Yucky hack to fix some layouts where the scroll view doesn't make it to the bottom on keyboard appearance.
var keyboardWillShowLayout: LayoutDescriptor?
init(viewModelContext: RoomScreenViewModel.Context) {
self.viewModelContext = viewModelContext
super.init()
viewModelContext.viewState.scrollToBottomPublisher
.sink { [weak self] _ in
self?.scrollToBottom(animated: true)
}
.store(in: &cancellables)
scrollAdapter.isScrolling
.sink { [weak self] isScrolling in
guard !isScrolling, let self, self.hasPendingUpdates else { return }
// When scrolling has stopped, apply any pending updates.
self.applySnapshot()
self.hasPendingUpdates = false
self.paginateBackwardsPublisher.send(())
}
.store(in: &cancellables)
paginateBackwardsPublisher
.collect(.byTime(DispatchQueue.main, 0.1))
.sink { [weak self] _ in
self?.paginateBackwardsIfNeeded()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
.sink { [weak self] _ in
guard let self else { return }
self.keyboardWillShowLayout = self.layout()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
.sink { [weak self] _ in
guard let self, let layout = self.keyboardWillShowLayout, layout.isBottomVisible else { return }
self.scrollToBottom(animated: false) // Force the bottom to be visible as some timelines misbehave.
}
.store(in: &cancellables)
}
/// Configures a diffable data source for the timeline's table view.
private func configureDataSource() {
guard let tableView else { return }
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
guard let self, let cell = cell as? TimelineItemCell else { return cell }
// A local reference to avoid capturing self in the cell configuration.
let viewModelContext = self.viewModelContext
cell.item = timelineItem
cell.contentConfiguration = UIHostingConfiguration {
timelineItem
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(viewModelContext.viewState.opacity(for: timelineItem))
.contextMenu {
viewModelContext.viewState.contextMenuBuilder?(timelineItem.id)
}
.onAppear {
viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
viewModelContext.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture {
viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id))
}
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
return cell
}
tableView.delegate = self
}
/// Adds an observer on the frame of the table view in order to keep the
/// last item visible when the keyboard is shown or the window resizes.
private func registerFrameObserver() {
// Remove the existing observer if necessary
frameObserverToken?.invalidate()
frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in
self?.handleFrameChange()
}
}
/// Updates the table's layout if necessary after the frame changed.
private nonisolated func handleFrameChange() {
Task { @MainActor in
guard self.composerMode == .default else { return }
// The table view is yet to update its layout so layout() returns a
// description of the timeline before the frame change occurs.
let previousLayout = self.layout()
if previousLayout.isBottomVisible {
self.scrollToBottom(animated: false)
}
}
}
/// Updates the table view's internal state from the view model's context.
func update() {
if timelineItems != viewModelContext.viewState.items {
timelineItems = viewModelContext.viewState.items
}
if isBackPaginating != viewModelContext.viewState.isBackPaginating {
isBackPaginating = viewModelContext.viewState.isBackPaginating
}
if composerMode != viewModelContext.viewState.composerMode {
composerMode = viewModelContext.viewState.composerMode
}
}
/// Updates the table view with the latest items from the ``timelineItems`` array. After
/// updating the data, the table will be scrolled to the bottom if it was visible otherwise
/// the scroll position will be updated to maintain the position of the last visible item.
private func applySnapshot() {
guard let dataSource else { return }
let previousLayout = layout()
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>()
snapshot.appendSections([.main])
snapshot.appendItems(timelineItems)
dataSource.apply(snapshot, animatingDifferences: false)
updateTopPadding()
guard snapshot.numberOfItems != previousLayout.numberOfItems else { return }
if previousLayout.isBottomVisible {
scrollToBottom(animated: false)
} else if let pinnedItem = previousLayout.pinnedItem {
restoreScrollPosition(using: pinnedItem, and: snapshot)
}
}
/// Reloads all of the visible timeline items.
///
/// This only needs to be called when some state internal to this table view changes that
/// will affect the appearance of those items. Any updates to the items themselves should
/// use ``applySnapshot()`` which handles everything in the diffable data source.
private func reloadVisibleItems() {
guard let tableView, let visibleIndexPaths = tableView.indexPathsForVisibleRows, let dataSource else { return }
var snapshot = dataSource.snapshot()
snapshot.reloadItems(visibleIndexPaths.compactMap { dataSource.itemIdentifier(for: $0) })
dataSource.apply(snapshot)
}
/// Returns a description of the current layout in order to update the
/// scroll position after adding/updating items to the timeline.
private func layout() -> LayoutDescriptor {
guard let tableView, let dataSource else { return LayoutDescriptor() }
let snapshot = dataSource.snapshot()
var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems)
guard !snapshot.itemIdentifiers.isEmpty else {
layout.isBottomVisible = true
return layout
}
guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last,
let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath)
else { return layout }
let bottomCellFrame = tableView.cellFrame(for: bottomItem)
layout.pinnedItem = PinnedItem(id: bottomItem.id, position: .bottom, frame: bottomCellFrame)
layout.isBottomVisible = bottomItem == snapshot.itemIdentifiers.last
return layout
}
/// Updates the additional padding added to the top of the table (via a header)
/// in order to fill the timeline from the bottom of the view upwards.
private func updateTopPadding() {
guard let tableView else { return }
let contentHeight = tableView.contentSize.height - (tableView.tableHeaderView?.frame.height ?? 0)
let height = tableView.visibleSize.height - contentHeight
if height > 0 {
let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height))
tableView.tableHeaderView = UIView(frame: frame) // Updating an existing view's height doesn't move the cells.
} else {
tableView.tableHeaderView = nil
}
}
/// Whether or not the bottom of the scroll view is visible (with some small tolerance added).
private func isAtBottom(of scrollView: UIScrollView) -> Bool {
scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.visibleSize.height - 15)
}
/// Scrolls to the bottom of the timeline.
private func scrollToBottom(animated: Bool) {
guard let lastItem = timelineItems.last,
let lastIndexPath = dataSource?.indexPath(for: lastItem)
else { return }
tableView?.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated)
}
/// Restores the position of the timeline using the supplied item and snapshot.
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>) {
guard let tableView,
let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }),
let indexPath = dataSource?.indexPath(for: item)
else { return }
// Scroll the item into view.
tableView.scrollToRow(at: indexPath, at: pinnedItem.position, animated: false)
guard let oldFrame = pinnedItem.frame, let newFrame = tableView.cellFrame(for: item) else { return }
// Remove any unwanted offset that was added by scrollToRow.
let deltaY = newFrame.maxY - oldFrame.maxY
if deltaY != 0 {
tableView.contentOffset.y += deltaY
}
}
/// Checks whether or a backwards pagination is needed and requests one if so.
///
/// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests.
private func paginateBackwardsIfNeeded() {
guard let tableView,
!isBackPaginating,
!hasPendingUpdates,
tableView.contentOffset.y < tableView.visibleSize.height * 2.0
else { return }
viewModelContext.send(viewAction: .paginateBackwards)
}
}
}
// MARK: - UITableViewDelegate
extension TimelineTableView.Coordinator: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let isAtBottom = isAtBottom(of: scrollView)
if !viewModelContext.scrollToBottomButtonVisible, isAtBottom {
DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = true }
} else if viewModelContext.scrollToBottomButtonVisible, !isAtBottom {
DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = false }
}
paginateBackwardsPublisher.send(())
}
// MARK: - ScrollViewAdapter
// Required delegate methods are forwarded to the adapter so others can be implemented.
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
scrollAdapter.scrollViewWillBeginDragging(scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
scrollAdapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate)
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
scrollAdapter.scrollViewDidEndScrollingAnimation(scrollView)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollAdapter.scrollViewDidEndDecelerating(scrollView)
}
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
scrollAdapter.scrollViewDidScrollToTop(scrollView)
}
}
// MARK: - Layout Types
extension TimelineTableView.Coordinator {
/// The sections of the table view used in the diffable data source.
enum TimelineSection { case main }
/// A description of the timeline's layout.
struct LayoutDescriptor {
var numberOfItems = 0
var pinnedItem: PinnedItem?
var isBottomVisible = false
}
/// An item that should have its position pinned after updates.
struct PinnedItem {
let id: String
let position: UITableView.ScrollPosition
let frame: CGRect?
}
}
// MARK: - Cell Layout
private extension UITableView {
/// Returns the frame of the cell for a particular timeline item.
func cellFrame(for item: RoomTimelineViewProvider) -> CGRect? {
guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item == item }) else {
return nil
}
return convert(timelineCell.frame, to: superview)
}
}
// MARK: - Previews
struct TimelineTableView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: "Preview room")
NavigationView {
RoomScreen(context: viewModel.context)
}
}
}
@@ -1,60 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
import SwiftUI
import Introspect
struct TimelineView: View {
@State private var visibleEdges: [VerticalEdge] = []
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@State private var scrollToBottomButtonVisible = false
var body: some View {
ZStack(alignment: .bottomTrailing) {
TimelineItemList(visibleEdges: $visibleEdges, scrollToBottomPublisher: scrollToBottomPublisher)
scrollToBottomButton
}
}
@ViewBuilder
private var scrollToBottomButton: some View {
Button { scrollToBottomPublisher.send(()) } label: {
Image(uiImage: Asset.Images.timelineScrollToBottom.image)
.shadow(radius: 2.0)
.padding()
}
.onChange(of: visibleEdges) { edges in
scrollToBottomButtonVisible = !edges.contains(.bottom)
}
.opacity(scrollToBottomButtonVisible ? 1.0 : 0.0)
.animation(.elementDefault, value: scrollToBottomButtonVisible)
}
}
struct TimelineView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: nil)
TimelineView()
.environmentObject(viewModel.context)
}
}
@@ -17,7 +17,7 @@
import Foundation
import MatrixRustSDK
struct MediaSourceProxy: Equatable {
struct MediaSourceProxy: Hashable {
let underlyingSource: MediaSource
init(source: MediaSource) {
@@ -32,9 +32,16 @@ struct MediaSourceProxy: Equatable {
underlyingSource.url()
}
// MARK: - Equatable
}
// MARK: - Hashable
extension MediaSource: Hashable {
public static func == (lhs: MediaSource, rhs: MediaSource) -> Bool {
lhs.url() == rhs.url()
}
static func == (lhs: MediaSourceProxy, rhs: MediaSourceProxy) -> Bool {
lhs.url == rhs.url
public func hash(into hasher: inout Hasher) {
hasher.combine(url())
}
}
@@ -17,7 +17,7 @@
import Foundation
/// Represents all reactions of the same type for a single event.
struct AggregatedReaction: Equatable, Hashable {
struct AggregatedReaction: Hashable {
/// The reaction that was sent.
let key: String
/// The number of times this reactions was sent.
@@ -24,7 +24,7 @@ protocol MessageContentProtocol: RoomMessageEventContentProtocol {
}
/// The delivery status for the item.
enum MessageTimelineItemDeliveryStatus: Equatable {
enum MessageTimelineItemDeliveryStatus: Hashable {
case unknown
case sending
case sent(elapsedTime: TimeInterval)
@@ -17,7 +17,7 @@
import Foundation
import UIKit
enum TimelineItemInGroupState {
enum TimelineItemInGroupState: Hashable {
case single
case beginning
case middle
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
var attributedComponents: [AttributedStringBuilderComponent]?
@@ -16,8 +16,8 @@
import UIKit
struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
enum EncryptionType: Equatable {
struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
enum EncryptionType: Hashable {
case megolmV1AesSha2(sessionId: String)
case olmV1Curve25519AesSha2(senderKey: String)
case unknown
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
let timestamp: String
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
let timestamp: String
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
var attributedComponents: [AttributedStringBuilderComponent]?
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
let timestamp: String
@@ -17,7 +17,7 @@
import Foundation
/// Properties of a matrix event that are common between all timeline items.
struct RoomTimelineItemProperties: Equatable {
struct RoomTimelineItemProperties: Hashable {
/// Whether the item has been edited.
var isEdited = false
/// The aggregated reactions that have been sent for this item.
@@ -16,7 +16,7 @@
import Foundation
struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Equatable {
struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
}
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
var attributedComponents: [AttributedStringBuilderComponent]?
@@ -17,7 +17,7 @@
import Foundation
import UIKit
struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable {
let id: String
let text: String
let timestamp: String
@@ -17,7 +17,7 @@
import Foundation
import SwiftUI
enum RoomTimelineViewProvider: Identifiable, Equatable {
enum RoomTimelineViewProvider: Identifiable, Hashable {
case text(TextRoomTimelineItem)
case separator(SeparatorRoomTimelineItem)
case image(ImageRoomTimelineItem)