Signal-iOS/Signal/src/ViewControllers/ConversationView/CV/CVUpdate.swift

327 lines
14 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
// 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], threadInteractionCount: UInt, shouldAnimateUpdate: Bool)
// MARK: -
public var debugName: String {
get {
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 }
enum Item: Equatable {
case insert(renderItem: CVRenderItem, newIndex: Int)
case update(renderItem: CVRenderItem, oldIndex: Int, newIndex: Int)
case delete(renderItem: CVRenderItem, oldIndex: Int)
// MARK: -
public var debugDescription: String {
get {
switch self {
case .insert(let renderItem, let newIndex):
return "insert(renderItem: \(renderItem.interactionTypeName), newIndex: \(newIndex))"
case .update(let renderItem, let oldIndex, let newIndex):
return "update(renderItem: \(renderItem.interactionTypeName), oldIndex: \(oldIndex), newIndex: \(newIndex))"
case .delete(let renderItem, let oldIndex):
return "delete(renderItem: \(renderItem.interactionTypeName), oldIndex: \(oldIndex))"
}
}
}
}
}
// MARK: -
extension CVUpdate {
typealias ItemId = String
private static func itemId(for renderItem: CVRenderItem) -> ItemId {
renderItem.interactionUniqueId
}
static func build(renderState: CVRenderState,
prevRenderState: CVRenderState,
loadRequest: CVLoadRequest,
threadInteractionCount: UInt) -> 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)
}
let newItems = renderState.items
let oldItems = prevRenderState.items
func buildItemMap(items: [CVRenderItem]) -> [ItemId: CVRenderItem] {
var itemMap = [ItemId: CVRenderItem]()
for item in items {
let itemId = Self.itemId(for: item)
owsAssertDebug(itemMap[itemId] == nil)
itemMap[itemId] = item
}
return itemMap
}
let oldItemMap = buildItemMap(items: oldItems)
let newItemMap = buildItemMap(items: newItems)
guard oldItemMap.count == oldItems.count,
newItemMap.count == newItems.count else {
owsFailDebug("Duplicate items.")
return buildUpdate(type: .reloadAll)
}
let oldItemIdList: [ItemId] = oldItems.map { self.itemId(for: $0) }
let newItemIdList: [ItemId] = newItems.map { self.itemId(for: $0) }
let oldItemIdSet = Set(oldItemIdList)
let newItemIdSet = Set(newItemIdList)
// We use sets and dictionaries here to ensure perf.
// We use NSMutableOrderedSet to preserve item ordering.
var deletedItemIdSet = OrderedSet<ItemId>(oldItemIdList)
deletedItemIdSet.remove(Array(newItemIdSet))
var insertedItemIdSet = OrderedSet<ItemId>(newItemIdList)
insertedItemIdSet.remove(Array(oldItemIdSet))
// Try to generate a series of "update items" that safely transform
// the "old item list" into the "new item list".
var updateItems = [CVUpdate.Item]()
// We simulate the outcome of the update using transformedItemList
// to check correctness.
var transformedItemList: [ItemId] = oldItemIdList
// 1. Deletes - Perform deletes before inserts and updates.
//
// NOTE: We perform deletes in descending order of item index,
// to avoid confusion around each deletion affecting the indices
// of subsequent deletions.
for itemId in deletedItemIdSet.orderedMembers.reversed() {
owsAssertDebug(oldItemIdSet.contains(itemId))
owsAssertDebug(!newItemIdSet.contains(itemId))
guard let oldIndex = oldItemIdList.firstIndex(of: itemId) else {
owsFailDebug("Can't find index of item.")
return buildUpdate(type: .reloadAll)
}
guard let renderItem = oldItemMap[itemId] else {
owsFailDebug("Can't find renderItem.")
return buildUpdate(type: .reloadAll)
}
updateItems.append(.delete(renderItem: renderItem, oldIndex: oldIndex))
Logger.verbose("remove at: \(oldIndex), \(renderItem.componentState.messageCellType)")
transformedItemList.remove(at: oldIndex)
}
// 2. Inserts - Perform inserts after deletes but before updates.
//
// NOTE: We perform inserts in ascending order of item index.
for itemId in insertedItemIdSet.orderedMembers {
owsAssertDebug(!oldItemIdSet.contains(itemId))
owsAssertDebug(newItemIdSet.contains(itemId))
guard let newIndex = newItemIdList.firstIndex(of: itemId) else {
owsFailDebug("Can't find index of item.")
return buildUpdate(type: .reloadAll)
}
guard let renderItem = newItemMap[itemId] else {
owsFailDebug("Can't find renderItem.")
return buildUpdate(type: .reloadAll)
}
updateItems.append(.insert(renderItem: renderItem, newIndex: newIndex))
if !DebugFlags.reduceLogChatter {
Logger.verbose("insert: \(itemId) at: \(newIndex), \(renderItem.componentState.messageCellType)")
}
transformedItemList.insert(itemId, at: newIndex)
}
guard newItemIdList == transformedItemList else {
// We should be able to represent all transformations as a series of
// inserts, updates and deletes - moves should not be necessary.
//
// TODO: The unread indicator might end up being an exception.
Logger.verbose("oldItemIdList: \(oldItemIdList)")
Logger.verbose("newItemIdList: \(newItemIdList)")
Logger.verbose("transformedItemList: \(transformedItemList)")
owsFailDebug("New and updated view item lists don't match.")
return buildUpdate(type: .reloadAll)
}
// 3. Updates - Perform updates last.
//
// In addition to items whose database (or derived) state has changed,
// we may need to update other items as well. One example is neighbors
// of changed cells. Another is cells whose appearance has changed due
// to the passage of time. We detect items who state or appearance has
// changed and update them.
//
// Order of updates doesn't matter.
let possiblyUpdatedItemIdSet = Set<ItemId>(newItemIdList).intersection(oldItemIdList)
var appearanceChangedItemIdSet = Set<ItemId>()
for itemId in possiblyUpdatedItemIdSet {
guard let oldRenderItem = oldItemMap[itemId] else {
owsFailDebug("Can't find renderItem.")
return buildUpdate(type: .reloadAll)
}
guard let newRenderItem = newItemMap[itemId] else {
owsFailDebug("Can't find renderItem.")
return buildUpdate(type: .reloadAll)
}
guard let oldIndex = oldItemIdList.firstIndex(of: itemId) else {
owsFailDebug("Can't find index of item.")
return buildUpdate(type: .reloadAll)
}
guard let newIndex = newItemIdList.firstIndex(of: itemId) else {
owsFailDebug("Can't find index of item.")
return buildUpdate(type: .reloadAll)
}
// Whenever the style changes we should update all cells.
if didStyleChange {
Logger.verbose("update: \(itemId) at: \(oldIndex) -> \(newIndex), \(newRenderItem.componentState.messageCellType)")
updateItems.append(.update(renderItem: newRenderItem, oldIndex: oldIndex, newIndex: newIndex))
continue
}
switch newRenderItem.updateMode(other: oldRenderItem) {
case .equal:
continue
case .stateChanged:
// The item changed, so we need to update it.
if !DebugFlags.reduceLogChatter {
Logger.verbose("update: \(itemId) at: \(oldIndex) -> \(newIndex), \(newRenderItem.componentState.messageCellType)")
}
updateItems.append(.update(renderItem: newRenderItem, oldIndex: oldIndex, newIndex: newIndex))
case .appearanceChanged:
// The item changed, so we need to update it.
if !DebugFlags.reduceLogChatter {
Logger.verbose("update: \(itemId) at: \(oldIndex) -> \(newIndex), \(newRenderItem.componentState.messageCellType)")
}
updateItems.append(.update(renderItem: newRenderItem, oldIndex: oldIndex, newIndex: newIndex))
// Take note of the fact that only the _appearance_ of the
// item changed, not its state.
appearanceChangedItemIdSet.insert(itemId)
}
}
guard !updateItems.isEmpty else {
return buildUpdate(type: .minor)
}
let shouldAnimateUpdate = Self.shouldAnimateUpdate(loadType: loadType,
updateItems: updateItems,
oldItemCount: oldItems.count,
appearanceChangedItemIdSet: appearanceChangedItemIdSet)
return buildUpdate(type: .diff(items: updateItems,
threadInteractionCount: threadInteractionCount,
shouldAnimateUpdate: shouldAnimateUpdate))
}
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 isOnlyModifyingLastMessage = true
for updateItem in updateItems {
guard isOnlyModifyingLastMessage else {
// Exit early if we already know that we're not just
// inserting a new message at the bottom of the conversation.
break
}
switch updateItem {
case .delete(let renderItem, _):
if renderItem.interactionType != .unreadIndicator {
isOnlyModifyingLastMessage = false
}
case .insert(let renderItem, let newIndex):
switch renderItem.interactionType {
case .incomingMessage, .outgoingMessage, .typingIndicator:
if newIndex < oldItemCount {
isOnlyModifyingLastMessage = false
}
case .unreadIndicator:
break
default:
isOnlyModifyingLastMessage = false
}
case .update(let renderItem, _, let newIndex):
let itemId = Self.itemId(for: renderItem)
let didOnlyAppearanceChange = appearanceChangedItemIdSet.contains(itemId)
if didOnlyAppearanceChange {
continue
}
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 {
isOnlyModifyingLastMessage = false
}
default:
isOnlyModifyingLastMessage = false
}
}
}
let shouldAnimateUpdate = !isOnlyModifyingLastMessage
return shouldAnimateUpdate
}
}