TM-SGNL-iOS/Signal/ConversationView/Loading/CVUpdate.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

216 lines
7.7 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
// The outcome of a CVC load is an update that describes how to
// transition from the last render state to the new render state.
struct CVUpdate {
enum CVUpdateType {
// No view items in the load window were effected.
case minor
// Complicated or unexpected changes occurred in the load window;
// the view should be reloaded.
case reloadAll
// A subset of view items in the load window were effected;
// the view should be updated using the update items.
//
// TODO: Do we need shouldAnimateUpdates? How does this fit with the scroll action?
case diff(items: [Item], shouldAnimateUpdate: Bool)
// MARK: -
public var debugName: String {
switch self {
case .minor:
return "minor"
case .reloadAll:
return "reloadAll"
case .diff:
return "diff"
}
}
}
let type: CVUpdateType
let renderState: CVRenderState
let prevRenderState: CVRenderState
let loadRequest: CVLoadRequest
var loadType: CVLoadType { loadRequest.loadType }
typealias Item = BatchUpdate<CVRenderItem>.Item
}
// MARK: -
extension CVUpdate {
typealias ItemId = String
// TODO: Eliminate.
private static func itemId(for renderItem: CVRenderItem) -> ItemId {
renderItem.interactionUniqueId
}
static func build(
renderState: CVRenderState,
prevRenderState: CVRenderState,
loadRequest: CVLoadRequest
) -> CVUpdate {
func buildUpdate(type: CVUpdateType) -> CVUpdate {
CVUpdate(type: type,
renderState: renderState,
prevRenderState: prevRenderState,
loadRequest: loadRequest)
}
let loadType = loadRequest.loadType
let oldStyle = prevRenderState.conversationStyle
let newStyle = renderState.conversationStyle
let didStyleChange = !newStyle.isEqualForCellRendering(oldStyle)
if case .loadInitialMapping = loadType {
// Don't do an incremental update for the initial load.
return buildUpdate(type: .reloadAll)
}
var appearanceChangedItemIdSet = Set<ItemId>()
var changedRenderItems = [CVRenderItem]()
var oldRenderItemMap = [String: CVRenderItem]()
for oldRenderItem in prevRenderState.items {
oldRenderItemMap[itemId(for: oldRenderItem)] = oldRenderItem
}
for newRenderItem in renderState.items {
let itemId = itemId(for: newRenderItem)
guard let oldRenderItem = oldRenderItemMap[itemId] else {
continue
}
// Whenever the style changes we should update all cells.
if didStyleChange {
changedRenderItems.append(newRenderItem)
continue
}
switch newRenderItem.updateMode(other: oldRenderItem) {
case .equal:
continue
case .stateChanged:
// The item changed, so we need to update it.
changedRenderItems.append(newRenderItem)
case .appearanceChanged:
// Take note of the fact that only the _appearance_ of the
// item changed, not its state.
appearanceChangedItemIdSet.insert(itemId)
// The item changed, so we need to update it.
changedRenderItems.append(newRenderItem)
}
}
do {
let batchUpdateItems = try BatchUpdate.build(viewType: .uiCollectionView,
oldValues: prevRenderState.items,
newValues: renderState.items,
changedValues: changedRenderItems)
guard !batchUpdateItems.isEmpty else {
return buildUpdate(type: .minor)
}
let oldItems = prevRenderState.items
let shouldAnimateUpdate = Self.shouldAnimateUpdate(loadType: loadType,
updateItems: batchUpdateItems,
oldItemCount: oldItems.count,
appearanceChangedItemIdSet: appearanceChangedItemIdSet)
return buildUpdate(type: .diff(
items: batchUpdateItems,
shouldAnimateUpdate: shouldAnimateUpdate
))
} catch {
owsFailDebug("Error: \(error)")
return buildUpdate(type: .reloadAll)
}
}
private static func shouldAnimateUpdate(loadType: CVLoadType,
updateItems: [CVUpdate.Item],
oldItemCount: Int,
appearanceChangedItemIdSet: Set<ItemId>) -> Bool {
switch loadType {
case .loadInitialMapping, .loadOlder, .loadNewer, .loadNewest, .loadPageAroundInteraction:
return false
case .loadSameLocation:
break
}
// If user sends a new outgoing message, animate the change.
var shouldAnimateUpdate = true
var onlyAppearanceUpdateChanges = true
var previousItemCount = oldItemCount
for updateItem in updateItems {
guard shouldAnimateUpdate else {
// Exit early if we already know that we're not just
// inserting a new message at the bottom of the conversation.
break
}
let renderItem = updateItem.value
switch updateItem.updateType {
case .delete:
onlyAppearanceUpdateChanges = false
previousItemCount = oldItemCount - 1
continue
case .insert(let newIndex):
onlyAppearanceUpdateChanges = false
switch renderItem.interactionType {
case .incomingMessage, .outgoingMessage, .typingIndicator:
// Allow animated insert if this item is one from the last item, as last item is likely a typing indicator
if newIndex < previousItemCount - 1 {
shouldAnimateUpdate = false
}
case .unreadIndicator:
break
default:
shouldAnimateUpdate = true
}
case .move:
onlyAppearanceUpdateChanges = false
case .update(_, let newIndex):
let itemId = Self.itemId(for: renderItem)
let didOnlyAppearanceChange = appearanceChangedItemIdSet.contains(itemId)
if didOnlyAppearanceChange {
continue
}
onlyAppearanceUpdateChanges = false
switch renderItem.interactionType {
case .incomingMessage, .outgoingMessage, .typingIndicator:
// We skip animations for the last _two_
// interactions, not one since there
// may be a typing indicator.
if newIndex + 2 > updateItems.count {
shouldAnimateUpdate = false
}
default:
shouldAnimateUpdate = false
}
}
}
return shouldAnimateUpdate && !onlyAppearanceUpdateChanges
}
}
// MARK: -
extension CVRenderItem: BatchUpdateValue {
public var batchUpdateId: String {
interactionUniqueId
}
}