Animate between compact & empty states of unread filter

This commit is contained in:
Adam Sharp 2024-08-06 12:45:16 -04:00
parent 2687cc1d01
commit 1ad2f51b5f
7 changed files with 249 additions and 20 deletions

View File

@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
0512145B2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0512145A2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift */; };
0517B9782BFCFF12002CDE7D /* TSThreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0517B9772BFCFF12002CDE7D /* TSThreadTests.swift */; };
052A33382C52BF410083D812 /* ChatListFilterActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052A33372C52BF410083D812 /* ChatListFilterActions.swift */; };
05412B3C2C22219E007AC9C7 /* InboxFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05412B3B2C22219E007AC9C7 /* InboxFilter.swift */; };
@ -17,6 +18,7 @@
0550A5E22C4035170072CC02 /* CLVViewInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0550A5E12C4035170072CC02 /* CLVViewInfo.swift */; };
0550A5E42C4048CF0072CC02 /* ChatListFilterFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0550A5E32C4048CF0072CC02 /* ChatListFilterFooterCell.swift */; };
05572BC42BFC0094006A72F1 /* DoubleTapToEditOnboardingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05572BC32BFC0094006A72F1 /* DoubleTapToEditOnboardingController.swift */; };
05B411252C62845000A1EDBC /* ChatListInboxFilterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B411242C62845000A1EDBC /* ChatListInboxFilterSection.swift */; };
0CE014267EDFBD2538E940A0 /* Pods_Signal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FF88FB580BC19B240EEB86A /* Pods_Signal.framework */; };
1404D8B3276A353B0068E2F6 /* ChatListViewController+Multiselect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1404D8B2276A353A0068E2F6 /* ChatListViewController+Multiselect.swift */; };
1466AB282817F7E7003B3D9F /* PluralAware.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 1466AB262817F7E7003B3D9F /* PluralAware.stringsdict */; };
@ -2949,6 +2951,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0512145A2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionDifference+SSK.swift"; sourceTree = "<group>"; };
0517B9772BFCFF12002CDE7D /* TSThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSThreadTests.swift; sourceTree = "<group>"; };
052A33372C52BF410083D812 /* ChatListFilterActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListFilterActions.swift; sourceTree = "<group>"; };
05412B3B2C22219E007AC9C7 /* InboxFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxFilter.swift; sourceTree = "<group>"; };
@ -2959,6 +2962,7 @@
0550A5E12C4035170072CC02 /* CLVViewInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLVViewInfo.swift; sourceTree = "<group>"; };
0550A5E32C4048CF0072CC02 /* ChatListFilterFooterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListFilterFooterCell.swift; sourceTree = "<group>"; };
05572BC32BFC0094006A72F1 /* DoubleTapToEditOnboardingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapToEditOnboardingController.swift; sourceTree = "<group>"; };
05B411242C62845000A1EDBC /* ChatListInboxFilterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListInboxFilterSection.swift; sourceTree = "<group>"; };
05E3A4DD8B4442530268AFC1 /* Pods-SignalShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.app store release.xcconfig"; path = "Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.app store release.xcconfig"; sourceTree = "<group>"; };
0BADD293DAFC82BF3274F0F6 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1404D8B2276A353A0068E2F6 /* ChatListViewController+Multiselect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListViewController+Multiselect.swift"; sourceTree = "<group>"; };
@ -9114,6 +9118,7 @@
052A33372C52BF410083D812 /* ChatListFilterActions.swift */,
0550A5DF2C3ECB230072CC02 /* ChatListFilterButton.swift */,
0550A5E32C4048CF0072CC02 /* ChatListFilterFooterCell.swift */,
05B411242C62845000A1EDBC /* ChatListInboxFilterSection.swift */,
50101FB32B08447000C648E4 /* ChatListProxyButtonCreator.swift */,
50101FB12B083C8100C648E4 /* ChatListSettingsButtonState.swift */,
34E95C26269F6095004807EC /* ChatListViewController+Actions.swift */,
@ -11830,6 +11835,7 @@
76387BEF28F4ED73002C7BA5 /* CaseIterable.swift */,
661396AC28BE74DC00E0C4DF /* ChainedPromise.swift */,
F9C5CB2A289453B200548EEE /* Collection+OWS.swift */,
0512145A2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift */,
34A955AB271B521500B05242 /* CommonStrings.swift */,
F962B389293F9F1F00765BD8 /* CRC32.swift */,
F9C5CB22289453B200548EEE /* Currency.swift */,
@ -13442,6 +13448,7 @@
052A33382C52BF410083D812 /* ChatListFilterActions.swift in Sources */,
0550A5E02C3ECB230072CC02 /* ChatListFilterButton.swift in Sources */,
0550A5E42C4048CF0072CC02 /* ChatListFilterFooterCell.swift in Sources */,
05B411252C62845000A1EDBC /* ChatListInboxFilterSection.swift in Sources */,
50101FB42B08447000C648E4 /* ChatListProxyButtonCreator.swift in Sources */,
50101FB22B083C8100C648E4 /* ChatListSettingsButtonState.swift in Sources */,
34E95C27269F6096004807EC /* ChatListViewController+Actions.swift in Sources */,
@ -14248,6 +14255,7 @@
C1CF83D02B96C85E00CDC9C4 /* ChunkedOutputStreamTransform.swift in Sources */,
728BFE522C5C59E5008F20F1 /* CipherContext.swift in Sources */,
F9C5CDFC289453B400548EEE /* Collection+OWS.swift in Sources */,
0512145B2C5BCECF0021EEC9 /* CollectionDifference+SSK.swift in Sources */,
72345D1D2B9A1984000237B3 /* CommonStrings.swift in Sources */,
66E1AD552B8D033E00C56B7B /* ConcreteTSResource.swift in Sources */,
66D7B92F2B98ECB00005C98B /* ConcreteTSResourceReference.swift in Sources */,

View File

@ -7,9 +7,25 @@ import SignalServiceKit
/// A snapshot combining both view database state used to render chat list table view rows.
struct CLVRenderState {
struct Section {
struct Section: Hashable, Identifiable {
var type: ChatListSectionType
var id: ChatListSectionType { type }
var threads: KeyPath<CLVRenderState, [TSThread]>?
var value: AnyHashable?
}
/// A type-erased representation of a row in a dynamic section of the chat
/// list, to support automatic diffing of rows.
struct RowItem: Hashable, Identifiable {
var section: ChatListSectionType
var id: AnyHashable
var value: AnyHashable
init(section: ChatListSectionType, value: some Hashable & Identifiable) {
self.section = section
self.id = value.id
self.value = value
}
}
static var empty: CLVRenderState {
@ -19,12 +35,15 @@ struct CLVRenderState {
let viewInfo: CLVViewInfo
let pinnedThreads: [TSThread]
let unpinnedThreads: [TSThread]
private(set) var sections: [Section] = []
private(set) var inboxFilterSection: ChatListInboxFilterSection?
init(viewInfo: CLVViewInfo, pinnedThreads: [TSThread], unpinnedThreads: [TSThread]) {
self.viewInfo = viewInfo
self.pinnedThreads = pinnedThreads
self.unpinnedThreads = unpinnedThreads
self.inboxFilterSection = ChatListInboxFilterSection(renderState: self)
self.sections = ChatListSectionType.allCases.compactMap(makeSection(for:))
}
@ -35,10 +54,12 @@ struct CLVRenderState {
case .unpinned:
return Section(type: sectionType, threads: \.unpinnedThreads)
case .reminders where hasVisibleReminders,
.archiveButton where hasArchivedThreadsRow,
.inboxFilterFooter where viewInfo.inboxFilter != nil:
.archiveButton where hasArchivedThreadsRow:
return Section(type: sectionType)
case .reminders, .archiveButton, .inboxFilterFooter:
case .inboxFilterFooter:
guard let inboxFilterSection else { return nil }
return Section(type: sectionType, value: inboxFilterSection)
case .reminders, .archiveButton:
return nil
}
}
@ -80,6 +101,35 @@ struct CLVRenderState {
}
}
/// For chat list sections that support dynamic content, compute a
/// collection difference of the rows in that section.
func sectionDifference(for section: Section, from renderState: CLVRenderState) -> CollectionDifference<RowItem>? {
switch section.type {
case .inboxFilterFooter:
let items = items(in: section) ?? []
let oldValue = renderState.items(in: section) ?? []
return items.difference(from: oldValue)
case .pinned, .unpinned, .reminders, .archiveButton:
return nil
}
}
private func items(in section: Section) -> [RowItem]? {
switch section.type {
case .inboxFilterFooter:
if let inboxFilterSection {
return [RowItem(section: section.type, value: inboxFilterSection)]
} else {
return nil
}
case .pinned, .unpinned, .reminders, .archiveButton:
owsFailDebug("Section diffing not yet supported in section '\(section.type)'")
return nil
}
}
func sectionIndex(for sectionType: ChatListSectionType) -> Int? {
sections.firstIndex(where: { $0.type == sectionType })
}

View File

@ -549,6 +549,12 @@ extension CLVTableDataSource: UITableViewDataSource {
let filterFooterCell = tableView.dequeueReusableCell(ChatListFilterFooterCell.self, for: indexPath)
filterFooterCell.primaryAction = .disableChatListFilter()
cell = filterFooterCell
guard let inboxFilterSection = renderState.inboxFilterSection else {
owsFailDebug("Missing view model in inbox filter section")
break
}
filterFooterCell.isExpanded = inboxFilterSection.isEmptyState
filterFooterCell.message = inboxFilterSection.message
}
cell.tintColor = .ows_accentBlue

View File

@ -0,0 +1,22 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
/// View model describing the current state of the inbox filter footer.
struct ChatListInboxFilterSection: Hashable, Identifiable {
let id = ChatListSectionType.inboxFilterFooter
var isEmptyState: Bool
var message: String? {
guard isEmptyState else { return nil }
return OWSLocalizedString("CHAT_LIST_UNREAD_FILTER_NO_CHATS", comment: "Message displayed on chat list when Filter by Unread is enabled but no unread chats are available")
}
init?(renderState: CLVRenderState) {
guard renderState.viewInfo.inboxFilter != nil else { return nil }
isEmptyState = renderState.visibleThreadCount == 0
}
}

View File

@ -106,10 +106,14 @@ extension ChatListViewController {
fileprivate func applyRowChanges(_ rowChanges: [CLVRowChange], renderState: CLVRenderState, animated: Bool) {
AssertIsOnMainThread()
let sectionDifference = renderState.sections.difference(from: tableDataSource.renderState.sections, by: { $0.type == $1.type })
let isChangingFilter = tableDataSource.renderState.viewInfo.inboxFilter != renderState.viewInfo.inboxFilter
let previousRenderState = tableDataSource.renderState
tableDataSource.renderState = renderState
let sectionChanges = renderState.sections
.difference(from: previousRenderState.sections)
.batchedChanges()
let isChangingFilter = previousRenderState.viewInfo.inboxFilter != renderState.viewInfo.inboxFilter
let tableView = self.tableView
let threadViewModelCache = self.threadViewModelCache
let cellContentCache = self.cellContentCache
@ -131,25 +135,20 @@ extension ChatListViewController {
}
}
// Ss soon as structural changes are applied to the table we can not use our optimized update implementation
// As soon as structural changes are applied to the table we can not use our optimized update implementation
// anymore. All indexPaths are based on the old model (before any change was applied) and if we
// animate move, insert and delete changes the indexPaths of the to be updated rows will differ.
var useFallBackUpdateMechanism = false
var removedSections = IndexSet()
var insertedSections = IndexSet()
for change in sectionDifference {
switch change {
case let .insert(offset, element: _, associatedWith: _):
insertedSections.insert(offset)
case let .remove(offset, element: _, associatedWith: _):
removedSections.insert(offset)
}
if !sectionChanges.removals.isEmpty {
checkAndSetTableUpdates()
tableView.deleteSections(sectionChanges.removals.offsets, with: .middle)
useFallBackUpdateMechanism = true
}
if !removedSections.isEmpty {
if !sectionChanges.insertions.isEmpty {
checkAndSetTableUpdates()
tableView.deleteSections(removedSections, with: .middle)
tableView.insertSections(sectionChanges.insertions.offsets, with: .fade)
useFallBackUpdateMechanism = true
}
@ -197,9 +196,41 @@ extension ChatListViewController {
}
}
if !insertedSections.isEmpty {
if !sectionChanges.updates.isEmpty {
checkAndSetTableUpdates()
tableView.insertSections(insertedSections, with: .top)
for (_, sectionUpdate) in sectionChanges.updates {
guard let rowChanges = renderState
.sectionDifference(for: sectionUpdate.element, from: previousRenderState)?
.batchedChanges()
else { continue }
let sectionIndex = sectionUpdate.offset
if !rowChanges.removals.isEmpty {
tableView.deleteRows(at: rowChanges.removals.indexPaths(in: sectionIndex), with: defaultRowAnimation)
}
if !rowChanges.insertions.isEmpty {
tableView.insertRows(at: rowChanges.insertions.indexPaths(in: sectionIndex), with: defaultRowAnimation)
}
if !rowChanges.updates.isEmpty {
if let previousSectionIndex = sectionUpdate.previousOffset, sectionIndex != previousSectionIndex {
// If the section index has changed, there's a good
// chance that the type of the cell has also changed
// (i.e., the `reuseIdentifier` last associated with that
// `indexPath`). This causes `reconfigureRows(at:)` to
// raise an assertion.
//
// Whenever the type of cell may have changed, we have
// to be conservative and reload instead of reconfiguring.
tableView.reloadRows(at: rowChanges.updates.indexPaths(in: previousSectionIndex), with: defaultRowAnimation)
} else {
tableView.reconfigureRows(at: rowChanges.updates.indexPaths(in: sectionIndex))
}
}
}
}
if tableUpdatesPerformed {

View File

@ -1048,6 +1048,9 @@
/* Title for context menu action to enable Filter by Unread */
"CHAT_LIST_UNREAD_FILTER_MENU_ACTION" = "Filter by Unread";
/* Message displayed on chat list when Filter by Unread is enabled but no unread chats are available */
"CHAT_LIST_UNREAD_FILTER_NO_CHATS" = "No unread chats";
/* Title for the color & wallpaper settings view. */
"COLOR_AND_WALLPAPER_SETTINGS_TITLE" = "Chat Color & Wallpaper";

View File

@ -0,0 +1,109 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
extension CollectionDifference where ChangeElement: Identifiable {
/// Represents a batch of changes derived from a `CollectionDifferance`,
/// suitable for applying to a `UITableView` or `UICollectionView`.
public struct ChangeBatch: Collection, ExpressibleByDictionaryLiteral {
public typealias Key = ChangeElement.ID
public typealias Value = (offset: Int, element: ChangeElement, previousOffset: Int?)
private var storage: [Key: Value]
public init() {
self.storage = [:]
}
public var offsets: IndexSet {
IndexSet(storage.values.lazy.map(\.offset))
}
public func indexPaths(in section: Int) -> [IndexPath] {
offsets.map { IndexPath(row: $0, section: section) }
}
public subscript(key: Key) -> Value? {
get { storage[key] }
set { storage[key] = newValue }
}
// MARK: ExpressibleByDictionaryLiteral
public init(dictionaryLiteral elements: (Key, Value)...) {
self.storage = Dictionary(uniqueKeysWithValues: elements)
}
// MARK: Collection
public typealias Index = Dictionary<Key, Value>.Index
public typealias Element = Dictionary<Key, Value>.Element
public var isEmpty: Bool {
storage.isEmpty
}
public var startIndex: Index {
storage.startIndex
}
public var endIndex: Index {
storage.endIndex
}
public func index(after i: Index) -> Index {
storage.index(after: i)
}
public subscript(position: Index) -> Element {
storage[position]
}
}
/// A combined set of batched changes derived from a `CollectionDifference`,
/// suitable for applying to a `UITableView` or `UICollectionView`.
public struct BatchedChanges {
/// An ordered set of all removals in the `CollectionDifference`.
public fileprivate(set) var removals = ChangeBatch()
/// An ordered set of all insertions in the `CollectionDifference`.
public fileprivate(set) var insertions = ChangeBatch()
/// An ordered set of all updates in the `CollectionDifference`, i.e.,
/// a combination of removal & insertion where the change element's `id`
/// is the same, but the element's value has changed.
public fileprivate(set) var updates = ChangeBatch()
}
/// Iterate over each change in the `CollectionDifference`, combining
/// changes of the same type (removal, insertion or update) into a
/// corresponding `ChangeBatch`.
///
/// An **update** change is defined as a **remove** followed by an **insert**
/// where the change elements `id` properties are equal. The original pair
/// of remove & insert operations are replaced by a single update in the
/// resulting `BatchedChanges`.
public func batchedChanges() -> BatchedChanges {
var changes = BatchedChanges()
for change in self {
switch change {
case let .remove(offset, element, associatedWith: _):
changes.removals[element.id] = (offset, element, nil)
case let .insert(offset, element, associatedWith: _):
if let removal = changes.removals[element.id] {
changes.removals[element.id] = nil
changes.updates[element.id] = (offset, element, previousOffset: removal.offset)
} else {
changes.insertions[element.id] = (offset, element, nil)
}
}
}
return changes
}
}