Refactor the NSDiffableDataSourceLayer (#1235)

* first part, using references

* better animations

* delete view provider and replaced wth two files

* ordered dictionary usage

* bubbled styler view previews

* plain style is back

* fix

* read marker previews restored

* updated tests

* code improvements

* better naming
This commit is contained in:
Mauro
2023-07-04 16:08:43 +02:00
committed by GitHub
parent eba3e5d5e6
commit b294278170
15 changed files with 376 additions and 316 deletions
@@ -18,6 +18,8 @@ import Combine
import SwiftUI
import UIKit
import OrderedCollections
enum RoomScreenViewModelAction {
case displayRoomDetails
case displayEmojiPicker(itemID: String)
@@ -84,7 +86,7 @@ struct RoomScreenViewState: BindableState {
var roomId: String
var roomTitle = ""
var roomAvatarURL: URL?
var items: [RoomTimelineViewProvider] = []
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
var members: [String: RoomMemberState] = [:]
var canBackPaginate = true
var isBackPaginating = false
@@ -102,6 +104,14 @@ struct RoomScreenViewState: BindableState {
var sendButtonDisabled: Bool {
bindings.composerText.count == 0
}
var itemIDs: [String] {
itemsDictionary.keys.elements
}
var itemViewModels: [RoomTimelineItemViewModel] {
itemsDictionary.values.elements
}
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
}
@@ -16,6 +16,7 @@
import Algorithms
import Combine
import OrderedCollections
import SwiftUI
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
@@ -116,7 +117,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .tappedOnUser(userID: let userID):
Task { await handleTappedUser(userID: userID) }
case .displayEmojiPicker(let itemID):
guard let item = state.items.first(where: { $0.id == itemID }), item.isReactable else { return }
guard let item = state.itemsDictionary[itemID], item.isReactable else { return }
callback?(.displayEmojiPicker(itemID: itemID))
case .reactionSummary(let itemId, let key):
showReactionSummary(for: itemId, selectedKey: key)
@@ -232,8 +233,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
private func buildTimelineViews() {
var timelineViews = [RoomTimelineViewProvider]()
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
canGroupItem(timelineItem: current, with: next)
}
@@ -246,24 +247,38 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
if itemGroup.count == 1 {
if let firstItem = itemGroup.first {
timelineViews.append(RoomTimelineViewProvider(timelineItem: firstItem, groupStyle: .single))
timelineItemsDictionary.updateValue(updateViewModel(item: firstItem, groupStyle: .single),
forKey: firstItem.id)
}
} else {
for (index, item) in itemGroup.enumerated() {
if index == 0 {
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, groupStyle: .first))
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .first),
forKey: item.id)
} else if index == itemGroup.count - 1 {
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, groupStyle: .last))
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .last),
forKey: item.id)
} else {
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, groupStyle: .middle))
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .middle),
forKey: item.id)
}
}
}
}
state.items = timelineViews
state.itemsDictionary = timelineItemsDictionary
}
private func updateViewModel(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewModel {
if let timelineItemViewModel = state.itemsDictionary[item.id] {
timelineItemViewModel.groupStyle = groupStyle
timelineItemViewModel.type = .init(item: item)
return timelineItemViewModel
} else {
return RoomTimelineItemViewModel(item: item, groupStyle: groupStyle)
}
}
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
return false
@@ -307,7 +307,7 @@ private extension View {
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View {
mockTimeline
.previewDisplayName("Mock Timeline")
@@ -320,40 +320,40 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
replies
.previewDisplayName("Replies")
}
static var mockTimeline: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.state.items) { item in
item.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
ForEach(viewModel.state.itemViewModels) { itemViewModel in
RoomTimelineItemView(viewModel: itemViewModel)
.padding(TimelineStyle.bubbles.rowInsets)
// Insets added in the table view cells
}
}
}
.environment(\.timelineStyle, .bubbles)
.environmentObject(viewModel.context)
}
static var replies: some View {
VStack {
RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
.single)
RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))),
.single)
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: "",
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))), groupStyle: .single))
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: "",
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))), groupStyle: .single))
}
.environmentObject(viewModel.context)
}
@@ -135,12 +135,12 @@ struct TimelineItemPlainStylerView<Content: View>: View {
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewProvider(timelineItem: item, groupStyle: .single)
RoomTimelineItemView(viewModel: .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.items.last?.id ?? UUID().uuidString
let id = viewModel.state.itemIDs.last ?? UUID().uuidString
var result = TextRoomTimelineItem(id: 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.items.last?.id ?? UUID().uuidString
let id = viewModel.state.itemIDs.last ?? UUID().uuidString
let result = TextRoomTimelineItem(id: id, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
return result
}()
@@ -23,14 +23,10 @@ struct TimelineItemStatusView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
private var isLastOutgoingMessage: Bool {
context.viewState.items.last?.id == timelineItem.id &&
context.viewState.itemIDs.last == timelineItem.id &&
timelineItem.isOutgoing
}
private var isLast: Bool {
context.viewState.items.last?.id == timelineItem.id
}
var body: some View {
if !timelineItem.properties.orderedReadReceipts.isEmpty, readReceiptsEnabled {
readReceipts
@@ -18,20 +18,20 @@ import SwiftUI
struct CollapsibleRoomTimelineView: View {
private let timelineItem: CollapsibleTimelineItem
private let timelineViews: [RoomTimelineViewProvider]
private let timelineViewModels: [RoomTimelineItemViewModel]
@State private var isExpanded = false
init(timelineItem: CollapsibleTimelineItem) {
self.timelineItem = timelineItem
timelineViews = timelineItem.items.map { RoomTimelineViewProvider(timelineItem: $0, groupStyle: .single) }
timelineViewModels = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
}
var body: some View {
DisclosureGroup(L10n.roomTimelineStateChanges(timelineItem.items.count), isExpanded: $isExpanded) {
Group {
ForEach(timelineViews) { timelineView in
timelineView.body
ForEach(timelineViewModels) { viewModel in
RoomTimelineItemView(viewModel: viewModel)
}
}.transition(.opacity.animation(.elementDefault))
}
@@ -37,27 +37,27 @@ struct ReadMarkerRoomTimelineView: View {
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static let item = ReadMarkerRoomTimelineItem()
static var previews: some View {
VStack(alignment: .leading, spacing: 0) {
RoomTimelineViewProvider.separator(.init(id: "Separator", text: "Today"), .single)
RoomTimelineViewProvider.text(.init(id: "",
timestamp: "",
isOutgoing: true,
isEditable: false,
sender: .init(id: "1", displayName: "Bob"),
content: .init(body: "This is another message")), .single)
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: "Separator", text: "Today")), groupStyle: .single))
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: "",
timestamp: "",
isOutgoing: true,
isEditable: false,
sender: .init(id: "1", displayName: "Bob"),
content: .init(body: "This is another message"))), groupStyle: .single))
ReadMarkerRoomTimelineView(timelineItem: item)
RoomTimelineViewProvider.separator(.init(id: "Separator", text: "Today"), .single)
RoomTimelineViewProvider.text(.init(id: "",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Alice"),
content: .init(body: "This is a message")), .single)
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: "Separator", text: "Today")), groupStyle: .single))
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: "",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Alice"),
content: .init(body: "This is a message"))), groupStyle: .single))
}
.padding(.horizontal, 8)
.environmentObject(viewModel.context)
@@ -17,12 +17,14 @@
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: RoomTimelineViewProvider?
var item: RoomTimelineItemViewModel?
override func prepareForReuse() {
item = nil
@@ -38,7 +40,7 @@ class TimelineTableViewController: UIViewController {
private let tableView = UITableView(frame: .zero, style: .plain)
var timelineStyle: TimelineStyle
var timelineItems: [RoomTimelineViewProvider] = [] {
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>() {
didSet {
guard !scrollAdapter.isScrolling.value else {
// Delay updating until scrolling has stopped as programatic
@@ -46,15 +48,15 @@ class TimelineTableViewController: UIViewController {
hasPendingUpdates = true
return
}
applySnapshot()
if timelineItems.isEmpty {
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
@@ -73,9 +75,13 @@ class TimelineTableViewController: UIViewController {
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemMenuActions?)?
@Binding private var scrollToBottomButtonVisible: Bool
private var timelineItemsIDs: [String] {
timelineItemsDictionary.keys.elements
}
/// The table's diffable data source.
private var dataSource: UITableViewDiffableDataSource<TimelineSection, RoomTimelineViewProvider>?
private var dataSource: UITableViewDiffableDataSource<TimelineSection, String>?
private var cancellables: Set<AnyCancellable> = []
/// The scroll view adapter used to detect whether scrolling is in progress.
@@ -183,24 +189,28 @@ class TimelineTableViewController: UIViewController {
/// Configures a diffable data source for the timeline's table view.
private func configureDataSource() {
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in
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
cell.item = timelineItem
let viewModel = timelineItemsDictionary[id]
cell.item = viewModel
guard let viewModel else {
return cell
}
cell.contentConfiguration = UIHostingConfiguration {
timelineItem
.id(timelineItem.id)
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(id: timelineItem.id))
coordinator.send(viewAction: .itemAppeared(id: id))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
coordinator.send(viewAction: .itemDisappeared(id: id))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
@@ -213,6 +223,8 @@ class TimelineTableViewController: UIViewController {
return cell
}
dataSource?.defaultRowAnimation = .fade
tableView.delegate = self
}
@@ -226,13 +238,13 @@ class TimelineTableViewController: UIViewController {
let previousLayout = layout()
self.previousLayout = previousLayout
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>()
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
snapshot.appendSections([.main])
snapshot.appendItems(timelineItems)
snapshot.appendItems(timelineItemsIDs)
dataSource.apply(snapshot, animatingDifferences: false)
updateTopPadding()
if previousLayout.isBottomVisible {
scrollToBottom(animated: false)
} else if let pinnedItem = previousLayout.pinnedItem {
@@ -254,12 +266,12 @@ class TimelineTableViewController: UIViewController {
}
guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last,
let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath)
let bottomID = 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
let bottomCellFrame = tableView.cellFrame(for: bottomID)
layout.pinnedItem = PinnedItem(id: bottomID, position: .bottom, frame: bottomCellFrame)
layout.isBottomVisible = bottomID == snapshot.itemIdentifiers.last
return layout
}
@@ -288,16 +300,16 @@ class TimelineTableViewController: UIViewController {
/// Scrolls to the bottom of the timeline.
private func scrollToBottom(animated: Bool) {
guard let lastItem = timelineItems.last,
let lastIndexPath = dataSource?.indexPath(for: lastItem)
guard let lastItemID = timelineItemsIDs.last,
let lastIndexPath = dataSource?.indexPath(for: lastItemID)
else { return }
tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated)
}
/// Restores the position of the timeline using the supplied item and snapshot.
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>) {
guard let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }),
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, String>) {
guard let item = snapshot.itemIdentifiers.first(where: { $0 == pinnedItem.id }),
let indexPath = dataSource?.indexPath(for: item)
else { return }
@@ -398,8 +410,8 @@ extension TimelineTableViewController {
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 {
func cellFrame(for id: String) -> CGRect? {
guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item?.id == id }) else {
return nil
}
@@ -46,7 +46,7 @@ struct TimelineView: UIViewControllerRepresentable {
init(viewModelContext: RoomScreenViewModel.Context) {
context = viewModelContext
if viewModelContext.viewState.items.isEmpty {
if viewModelContext.viewState.itemViewModels.isEmpty {
viewModelContext.send(viewAction: .paginateBackwards)
}
}
@@ -56,8 +56,8 @@ struct TimelineView: UIViewControllerRepresentable {
if tableViewController.timelineStyle != timelineStyle {
tableViewController.timelineStyle = timelineStyle
}
if tableViewController.timelineItems != context.viewState.items {
tableViewController.timelineItems = context.viewState.items
if tableViewController.timelineItemsDictionary != context.viewState.itemsDictionary {
tableViewController.timelineItemsDictionary = context.viewState.itemsDictionary
}
if tableViewController.canBackPaginate != context.viewState.canBackPaginate {
tableViewController.canBackPaginate = context.viewState.canBackPaginate