From 08dabd70d218d4db71e7dd5d3eff58766e2feebe Mon Sep 17 00:00:00 2001 From: Harry <109690906+harry-signal@users.noreply.github.com> Date: Mon, 24 Oct 2022 15:38:54 -0700 Subject: [PATCH] Story list search * Hide navigation bar when searching in chats list * Incomplete search bar layout * apply story search filtering * hide my stories section when searching * Improve transitions for story tab bar search * Show hidden stories in search results * visual fixes to header when searching. include all unfiltered stories in viewer contexts * delay getting the target story frame for interactive dismissal to give the view time to update after navigation bar changes * fix strings * pr feedback --- .../Chat List/ChatListViewController.m | 13 +- .../Stories/StoriesViewController.swift | 185 +++++++++++++-- .../Stories/StoryListDataSource.swift | 223 +++++++++++++----- .../Transitions/StoryZoomAnimator.swift | 21 +- 4 files changed, 352 insertions(+), 90 deletions(-) diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.m b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.m index d96c440a08..8734ffb48b 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.m +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.m @@ -665,6 +665,7 @@ NSString *const kArchiveButtonPseudoGroup = @"kArchiveButtonPseudoGroup"; } [self.searchResultsController viewWillAppear:animated]; + [self ensureSearchBarCancelButton]; [self updateUnreadPaymentNotificationsCountWithSneakyTransaction]; @@ -696,6 +697,7 @@ NSString *const kArchiveButtonPseudoGroup = @"kArchiveButtonPseudoGroup"; self.isViewVisible = NO; [self.searchResultsController viewWillDisappear:animated]; + [self.navigationController setNavigationBarHidden:NO animated:animated]; } - (void)updateLastViewedThread:(TSThread *)thread animated:(BOOL)animated @@ -769,10 +771,15 @@ NSString *const kArchiveButtonPseudoGroup = @"kArchiveButtonPseudoGroup"; - (void)ensureSearchBarCancelButton { BOOL shouldShowCancelButton = (self.searchBar.isFirstResponder || self.searchBar.text.length > 0); - if (self.searchBar.showsCancelButton == shouldShowCancelButton) { - return; + BOOL shouldHideNavigationBar = shouldShowCancelButton && self.isViewVisible; + if (self.searchBar.showsCancelButton != shouldShowCancelButton) { + [self.searchBar setShowsCancelButton:shouldShowCancelButton animated:self.isViewVisible]; + } + if (self.navigationController != nil) { + if (self.navigationController.isNavigationBarHidden != shouldHideNavigationBar) { + [self.navigationController setNavigationBarHidden:shouldHideNavigationBar animated: self.isViewVisible]; + } } - [self.searchBar setShowsCancelButton:shouldShowCancelButton animated:self.isViewVisible]; } - (void)updateSearchResultsVisibility diff --git a/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift index 31800e8739..6faa7e9ac7 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift @@ -13,6 +13,22 @@ import UIKit class StoriesViewController: OWSViewController, StoryListDataSourceDelegate { let tableView = UITableView() + let searchBarBackdropView = UIView() + + let searchBarContainer = UIView() + let searchBar = OWSSearchBar() + + var searchBarScrollingConstraint: NSLayoutConstraint? + var searchBarPinnedConstraint: NSLayoutConstraint? + + var isFocusingSearchBar = false { + didSet { + searchBarBackdropView.isHidden = !isFocusingSearchBar + searchBarScrollingConstraint?.isActive = !isFocusingSearchBar + searchBarPinnedConstraint?.isActive = isFocusingSearchBar + } + } + private lazy var emptyStateLabel: UILabel = { let label = UILabel() label.textColor = Theme.secondaryTextAndIconColor @@ -40,19 +56,49 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate { NotificationCenter.default.addObserver(self, selector: #selector(profileDidChange), name: .localProfileDidChange, object: nil) } - override func loadView() { - view = tableView - tableView.delegate = self - tableView.dataSource = self - } - var tableViewIfLoaded: UITableView? { - return viewIfLoaded as? UITableView + return viewIfLoaded == nil ? nil : tableView } override func viewDidLoad() { super.viewDidLoad() + view.addSubview(tableView) + tableView.autoPinEdgesToSuperviewEdges() + tableView.delegate = self + tableView.dataSource = self + + // Search + searchBarContainer.layoutMargins = .init(hMargin: 8, vMargin: 0) + + // Comment is wrong but its the same string... + searchBar.placeholder = OWSLocalizedString( + "HOME_VIEW_CONVERSATION_SEARCHBAR_PLACEHOLDER", + comment: "Placeholder text for search bar which filters conversations." + ) + searchBar.delegate = self + searchBar.sizeToFit() + searchBar.layoutMargins = .zero + + searchBarContainer.frame = searchBar.frame + searchBarContainer.addSubview(searchBar) + searchBar.autoPinEdgesToSuperviewMargins() + + let searchBarSizingView = UIView() + searchBarSizingView.frame = searchBarContainer.frame + self.tableView.tableHeaderView = searchBarSizingView + + searchBarSizingView.addSubview(searchBarContainer) + searchBarContainer.autoPinHorizontalEdges(toEdgesOf: view) + self.searchBarScrollingConstraint = searchBarContainer.autoPinEdge(.top, to: .top, of: searchBarSizingView) + self.searchBarPinnedConstraint = searchBarContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) + + view.insertSubview(searchBarBackdropView, aboveSubview: tableView) + searchBarBackdropView.isHidden = true + searchBarBackdropView.backgroundColor = Theme.backgroundColor + searchBarBackdropView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) + searchBarBackdropView.autoPinEdge(.bottom, to: .top, of: searchBarContainer) + title = NSLocalizedString("STORIES_TITLE", comment: "Title for the stories view.") tableView.register(MyStoryCell.self, forCellReuseIdentifier: MyStoryCell.reuseIdentifier) @@ -89,11 +135,20 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate { } } } + + if isFocusingSearchBar { + navigationController?.setNavigationBarHidden(true, animated: false) + (tabBarController as? HomeTabBarController)?.setTabBarHidden(true, animated: false) + } } + private var viewIsAppeared = false + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + self.viewIsAppeared = true + // Whether or not the theme has changed, always ensure // the right theme is applied. The initial collapsed // state of the split view controller is determined between @@ -117,8 +172,24 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + defer { + self.viewIsAppeared = false + } + timestampUpdateTimer?.invalidate() timestampUpdateTimer = nil + + navigationController?.setNavigationBarHidden(false, animated: animated) + (tabBarController as? HomeTabBarController)?.setTabBarHidden(false, animated: animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if !searchBar.text.isEmptyOrNil { + searchBar.text = nil + dataSource.setSearchText(nil) + } } override func applyTheme() { @@ -155,6 +226,8 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate { view.backgroundColor = Theme.backgroundColor tableView.backgroundColor = Theme.backgroundColor + searchBarContainer.backgroundColor = Theme.backgroundColor + searchBarBackdropView.backgroundColor = Theme.backgroundColor updateNavigationBar() } @@ -276,9 +349,11 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate { } else if sectionConstraint ?? .hiddenStories == .hiddenStories, let hiddenStoryIndex = dataSource.hiddenStories.firstIndex(where: { $0.context == context }), - !dataSource.isHiddenStoriesSectionCollapsed { + dataSource.shouldDisplayHiddenStories { section = .hiddenStories - index = hiddenStoryIndex + // Offset for the header + let headerOffset = dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0 + index = hiddenStoryIndex + headerOffset } else { // Not found. return @@ -301,6 +376,13 @@ extension StoriesViewController: CameraFirstCaptureDelegate { } extension StoriesViewController: UITableViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if isFocusingSearchBar, searchBar.text?.isEmpty ?? true { + stopFocusingSearchBar(clearingSearchText: true) + } + } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) @@ -312,7 +394,7 @@ extension StoriesViewController: UITableViewDelegate { navigationController?.pushViewController(MyStoriesViewController(), animated: true) } case .hiddenStories: - if indexPath.row == 0 { + if indexPath.row == 0, dataSource.shouldDisplayHiddenStoriesHeader { // Tapping the collapsing header. let wasCollapsed = dataSource.isHiddenStoriesSectionCollapsed dataSource.isHiddenStoriesSectionCollapsed = !wasCollapsed @@ -433,7 +515,8 @@ extension StoriesViewController: UITableViewDataSource { return dataSource.visibleStories[safe: indexPath.row] case .hiddenStories: // Offset by 1 to account for the header cell. - return dataSource.hiddenStories[safe: indexPath.row - 1] + let headerOffset = dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0 + return dataSource.hiddenStories[safe: indexPath.row - headerOffset] case .myStory, .none: return nil } @@ -448,10 +531,11 @@ extension StoriesViewController: UITableViewDataSource { if let visibleRow = dataSource.visibleStories.firstIndex(where: { $0.context == context }) { indexPath = IndexPath(row: visibleRow, section: Section.visibleStories.rawValue) } else if - !dataSource.isHiddenStoriesSectionCollapsed, + dataSource.shouldDisplayHiddenStories, let hiddenRow = dataSource.hiddenStories.firstIndex(where: { $0.context == context }) { // Offset by 1 to account for the header cell. - indexPath = IndexPath(row: hiddenRow + 1, section: Section.hiddenStories.rawValue) + let headerOffset = dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0 + indexPath = IndexPath(row: hiddenRow + headerOffset, section: Section.hiddenStories.rawValue) } else { return nil } @@ -470,7 +554,7 @@ extension StoriesViewController: UITableViewDataSource { cell.configure(with: myStoryModel) { [weak self] in self?.showCameraView() } return cell case .hiddenStories: - if indexPath.row == 0 { + if indexPath.row == 0 && dataSource.shouldDisplayHiddenStoriesHeader { let cell = tableView.dequeueReusableCell( withIdentifier: HiddenStoryHeaderCell.reuseIdentifier, for: indexPath @@ -503,14 +587,15 @@ extension StoriesViewController: UITableViewDataSource { switch Section(rawValue: section) { case .myStory: - return dataSource.myStory == nil ? 0 : 1 + return dataSource.shouldDisplayMyStory ? 1 : 0 case .visibleStories: return dataSource.visibleStories.count case .hiddenStories: - guard !dataSource.hiddenStories.isEmpty else { - return 0 - } - return dataSource.isHiddenStoriesSectionCollapsed ? 1 : dataSource.hiddenStories.count + 1 + return ( + dataSource.shouldDisplayHiddenStoriesHeader ? 1 : 0 + ) + ( + dataSource.shouldDisplayHiddenStories ? dataSource.hiddenStories.count : 0 + ) case .none: owsFailDebug("Unexpected section \(section)") return 0 @@ -518,6 +603,68 @@ extension StoriesViewController: UITableViewDataSource { } } +extension StoriesViewController: UISearchBarDelegate { + + func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { + return viewIsAppeared + } + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + beginFocusingSearchBar() + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + dataSource.setSearchText(searchText.isEmpty ? nil : searchText) + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + stopFocusingSearchBar(clearingSearchText: false) + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + stopFocusingSearchBar(clearingSearchText: true) + } + + private func beginFocusingSearchBar() { + self.navigationController?.setNavigationBarHidden(true, animated: true) + (tabBarController as? HomeTabBarController)?.setTabBarHidden(true, animated: true) + searchBar.setShowsCancelButton(true, animated: true) + // Do this as a transition animation so we get tighter timing + // with the navigation controller animation. + UIView.transition( + with: view, + duration: UINavigationController.hideShowBarDuration, + animations: {}, + completion: { _ in + // Only change state when animations are done. + self.isFocusingSearchBar = true + } + ) + } + + private func stopFocusingSearchBar(clearingSearchText: Bool) { + self.navigationController?.setNavigationBarHidden(false, animated: true) + (tabBarController as? HomeTabBarController)?.setTabBarHidden(false, animated: true) + searchBar.setShowsCancelButton(false, animated: true) + // Do this as a transition animation so we get tighter timing + // with the navigation controller animation. + UIView.transition( + with: self.view, + duration: UINavigationController.hideShowBarDuration, + animations: {}, + completion: { _ in + // Only change state when animations are done. + self.isFocusingSearchBar = false + } + ) + searchBar.resignFirstResponder() + if clearingSearchText { + self.searchBar.text = nil + dataSource.setSearchText(nil) + } + } +} + extension StoriesViewController: StoryPageViewControllerDataSource { func storyPageViewControllerAvailableContexts( _ storyPageViewController: StoryPageViewController, diff --git a/Signal/src/ViewControllers/HomeView/Stories/StoryListDataSource.swift b/Signal/src/ViewControllers/HomeView/Stories/StoryListDataSource.swift index 308f47ff34..3cfde8b6b4 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/StoryListDataSource.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/StoryListDataSource.swift @@ -45,7 +45,7 @@ class StoryListDataSource: NSObject, Dependencies { } var allStories: [StoryViewModel] { - return syncingModels.exposedModel.stories + return syncingModels.exposedModel.unfilteredStories } var visibleStories: [StoryViewModel] { @@ -68,6 +68,14 @@ class StoryListDataSource: NSObject, Dependencies { return syncingModels.threadSafeHiddenStoryContexts } + public var shouldDisplayMyStory: Bool { + return syncingModels.exposedModel.shouldDisplayMyStory + } + + public var shouldDisplayHiddenStoriesHeader: Bool { + return syncingModels.exposedModel.shouldDisplayHiddenStoriesHeader + } + public var isHiddenStoriesSectionCollapsed: Bool { get { return syncingModels.exposedModel.isHiddenStoriesSectionCollapsed @@ -77,25 +85,34 @@ class StoryListDataSource: NSObject, Dependencies { } } + public var shouldDisplayHiddenStories: Bool { + return syncingModels.exposedModel.shouldDisplayHiddenStories + } + + public func setSearchText(_ text: String?) { + updateStoriesForSearchText(text) + } + // MARK: - Reload func reloadStories() { loadingQueue.async { self.syncingModels.mutate { oldModel -> StoryChanges? in - let (listStories, outgoingStories) = Self.databaseStorage.read { - ( - StoryFinder.storiesForListView(transaction: $0), - StoryFinder.outgoingStories(transaction: $0) - ) + let (listStories, myStoryModel) = Self.databaseStorage.read { transaction -> ([StoryViewModel], MyStoryViewModel) in + let storiesForList = StoryFinder.storiesForListView(transaction: transaction) + let groupedMessages = Dictionary(grouping: storiesForList) { $0.context } + let storyListModels = groupedMessages.compactMap { _, messages -> StoryViewModel? in + return try? StoryViewModel(messages: messages, transaction: transaction) + } + let outgoingStories = StoryFinder.outgoingStories(transaction: transaction) + let myStoryModel = MyStoryViewModel(messages: outgoingStories, transaction: transaction) + + return (storyListModels, myStoryModel) } - let myStoryModel = Self.databaseStorage.read { MyStoryViewModel(messages: outgoingStories, transaction: $0) } - let groupedMessages = Dictionary(grouping: listStories) { $0.context } - let newValues = Self.databaseStorage.read { transaction in - groupedMessages.compactMap { try? StoryViewModel(messages: $1, transaction: transaction) } - }.sorted(by: Self.sortStoryModels) let newModel = StoryListViewModel( myStory: myStoryModel, - stories: newValues, + stories: listStories.sorted(by: Self.sortStoryModels), + searchText: oldModel.searchText, isHiddenStoriesSectionCollapsed: oldModel.isHiddenStoriesSectionCollapsed ) // Note everything but the new model gets ignored since we reload data @@ -171,6 +188,32 @@ class StoryListDataSource: NSObject, Dependencies { } } + private func updateStoriesForSearchText(_ searchText: String?) { + loadingQueue.async { + self.syncingModels.mutate { oldModel -> StoryChanges? in + let newModel = StoryListViewModel( + myStory: oldModel.myStory, + stories: oldModel.unfilteredStories, + searchText: searchText, + isHiddenStoriesSectionCollapsed: oldModel.isHiddenStoriesSectionCollapsed + ) + // Note everything but the new model gets ignored since we reload data + // rather than apply individual row updates. + return StoryChanges( + oldModel: oldModel, + newModel: newModel, + visibleStoryUpdates: [], // is ignored + hiddenStoryUpdates: [], // is ignored + myStoryChanged: true // is ignored + ) + } sync: { _ in + self.tableView?.reloadData() + self.observeAssociatedDataChangesForAvailableModels() + self.delegate?.tableViewDidUpdate() + } + } + } + private func updateStories(_ mutate: @escaping (StoryListViewModel) -> StoryChanges?) { AssertIsOnMainThread() @@ -335,7 +378,7 @@ class StoryListDataSource: NSObject, Dependencies { var changedHiddenContexts = [StoryContext]() let newModels = try Self.databaseStorage.read { transaction -> [StoryViewModel] in - let changedModels = try oldViewModel.stories.compactMap { oldModel -> StoryViewModel? in + let changedModels = try oldViewModel.unfilteredStories.compactMap { oldModel -> StoryViewModel? in guard let latestMessage = oldModel.messages.first else { return oldModel } let modelDeletedRowIds: [Int64] = oldModel.messages.lazy.compactMap(\.id).filter { deletedRowIds.contains($0) } @@ -391,7 +434,7 @@ class StoryListDataSource: NSObject, Dependencies { let newIsHiddenStoriesSectionCollapsed: Bool if !oldViewModel.isHiddenStoriesSectionCollapsed { newIsHiddenStoriesSectionCollapsed = false - } else if oldViewModel.hiddenStories.isEmpty && newModels.contains(where: \.isHidden) { + } else if oldViewModel.unfilteredHiddenStories.isEmpty && newModels.contains(where: \.isHidden) { newIsHiddenStoriesSectionCollapsed = false } else { newIsHiddenStoriesSectionCollapsed = true @@ -400,6 +443,7 @@ class StoryListDataSource: NSObject, Dependencies { let newViewModel = StoryListViewModel( myStory: myStoryModel ?? oldViewModel.myStory, stories: newModels, + searchText: oldViewModel.searchText, isHiddenStoriesSectionCollapsed: newIsHiddenStoriesSectionCollapsed ) @@ -482,13 +526,12 @@ class StoryListDataSource: NSObject, Dependencies { self.delegate?.tableViewDidUpdate() } - if changes.oldModel.myStory == nil, changes.newModel.myStory != nil { + if !changes.oldModel.shouldDisplayMyStory, changes.newModel.shouldDisplayMyStory { tableView.insertRows(at: [IndexPath(row: 0, section: Section.myStory.rawValue)], with: .fade) - } else if changes.oldModel.myStory != nil, changes.newModel.myStory == nil { - // My story should never go away after being loaded, but for the sake of completeness... + } else if changes.oldModel.shouldDisplayMyStory, !changes.newModel.shouldDisplayMyStory { tableView.deleteRows(at: [IndexPath(row: 0, section: Section.myStory.rawValue)], with: .fade) - } else if changes.myStoryChanged { - tableView.reloadRows(at: [IndexPath(row: 0, section: Section.myStory.rawValue)], with: .none) + } else if changes.myStoryChanged, changes.newModel.shouldDisplayMyStory { + tableView.reloadRows(at: [IndexPath(row: 0, section: Section.myStory.rawValue)], with: .fade) } // Visible stories section is always visible, directly apply changes. @@ -503,13 +546,14 @@ class StoryListDataSource: NSObject, Dependencies { return } - switch (changes.oldModel.hiddenStories.isEmpty, changes.newModel.hiddenStories.isEmpty) { - - case (true, true): - // No need to do anything. - return + // Header + switch (changes.oldModel.shouldDisplayHiddenStoriesHeader, changes.newModel.shouldDisplayHiddenStoriesHeader) { case (false, false): + // No need to do anything. + break + + case (true, true): // Just reload the header row if we have to. if changes.oldModel.isHiddenStoriesSectionCollapsed != changes.newModel.isHiddenStoriesSectionCollapsed { // If the cell is visible, reconfigure it directly without reloading. @@ -523,68 +567,84 @@ class StoryListDataSource: NSObject, Dependencies { tableView.reloadRows(at: [path], with: .none) } } - case (true, false): - tableView.insertRows(at: [IndexPath(row: 0, section: Section.hiddenStories.rawValue)], with: .fade) + case (false, true): + tableView.insertRows(at: [IndexPath(row: 0, section: Section.hiddenStories.rawValue)], with: .fade) + case (true, false): tableView.deleteRows(at: [IndexPath(row: 0, section: Section.hiddenStories.rawValue)], with: .fade) - applyTableViewBatchUpdates( - changes.oldModel.hiddenStories.lazy.enumerated().map { - // Offset by 1 to account for the header cell. - return .init(value: $1.context, updateType: .delete(oldIndex: $0 + 1)) - }, - toSection: .hiddenStories, - models: changes.oldModel.hiddenStories - ) - return } - switch (changes.oldModel.isHiddenStoriesSectionCollapsed, changes.newModel.isHiddenStoriesSectionCollapsed) { + // Offset by 1 to account for the header cell. + let oldHeaderOffset = changes.oldModel.shouldDisplayHiddenStoriesHeader ? 1 : 0 + let newHeaderOffset = changes.newModel.shouldDisplayHiddenStoriesHeader ? 1 : 0 - case (false, false): - // Update the hidden section, it was expanded before and after + switch (changes.oldModel.shouldDisplayHiddenStories, changes.newModel.shouldDisplayHiddenStories) { + + case (true, true): + // Update the hidden section, it was shown before and after applyTableViewBatchUpdates( changes.hiddenStoryUpdates.map { - // Offset by 1 to account for the header cell. switch $0.updateType { case let .update(oldIndex, newIndex): - return .init(value: $0.value, updateType: .update(oldIndex: oldIndex + 1, newIndex: newIndex + 1)) + return .init( + value: $0.value, + updateType: .update( + oldIndex: oldIndex + oldHeaderOffset, + newIndex: newIndex + newHeaderOffset + ) + ) case let .move(oldIndex, newIndex): - return .init(value: $0.value, updateType: .move(oldIndex: oldIndex + 1, newIndex: newIndex + 1)) + return .init( + value: $0.value, + updateType: .move( + oldIndex: oldIndex + oldHeaderOffset, + newIndex: newIndex + newHeaderOffset + ) + ) case let .insert(newIndex): - return .init(value: $0.value, updateType: .insert(newIndex: newIndex + 1)) + return .init( + value: $0.value, + updateType: .insert( + newIndex: newIndex + newHeaderOffset + ) + ) case let .delete(oldIndex): - return .init(value: $0.value, updateType: .delete(oldIndex: oldIndex + 1)) + return .init( + value: $0.value, + updateType: .delete( + oldIndex: oldIndex + oldHeaderOffset + ) + ) } }, toSection: .hiddenStories, - // Offset by 1 to account for the header cell. - models: [changes.newModel.hiddenStories.first].compactMap({ $0 }) + changes.newModel.hiddenStories + models: [changes.newModel.hiddenStories.first].compactMap({ + changes.newModel.shouldDisplayHiddenStoriesHeader ? $0 : nil + }) + changes.newModel.hiddenStories ) - case (true, false): - // Was collapsed and is now expanded, reload. + case (false, true): + // Was hidden and is now shown, reload. applyTableViewBatchUpdates( changes.newModel.hiddenStories.lazy.enumerated().map { - // Offset by 1 to account for the header cell. - return .init(value: $1.context, updateType: .insert(newIndex: $0 + 1)) + return .init(value: $1.context, updateType: .insert(newIndex: $0 + newHeaderOffset)) }, toSection: .hiddenStories, models: changes.newModel.hiddenStories ) - case (false, true): - // Was expanded and is now collapsed, everything counts as a delete. + case (true, false): + // Was shown and is now hidden, everything counts as a delete. applyTableViewBatchUpdates( changes.oldModel.hiddenStories.lazy.enumerated().map { - // Offset by 1 to account for the header cell. - return .init(value: $1.context, updateType: .delete(oldIndex: $0 + 1)) + return .init(value: $1.context, updateType: .delete(oldIndex: $0 + oldHeaderOffset)) }, toSection: .hiddenStories, models: changes.oldModel.hiddenStories ) - case (true, true): - // Was collapsed and is collapsed, so can just ignore any updates. + case (false, false): + // Was hidden and is hidden, so can just ignore any updates. break } } @@ -717,28 +777,75 @@ private class SyncingStoryListViewModel { private struct StoryListViewModel { let myStory: MyStoryViewModel? - let stories: [StoryViewModel] + let unfilteredStories: [StoryViewModel] + let searchText: String? let isHiddenStoriesSectionCollapsed: Bool + let stories: [StoryViewModel] + + init( + myStory: MyStoryViewModel?, + stories: [StoryViewModel], + searchText: String?, + isHiddenStoriesSectionCollapsed: Bool + ) { + self.myStory = myStory + self.unfilteredStories = stories + self.searchText = searchText + self.isHiddenStoriesSectionCollapsed = isHiddenStoriesSectionCollapsed + + self.stories = { + guard let searchText = searchText else { + return stories + } + return stories.filter { + $0.latestMessageName.localizedCaseInsensitiveContains(searchText) + } + }() + } + static var empty: Self { - return .init(myStory: nil, stories: [], isHiddenStoriesSectionCollapsed: true) + return .init(myStory: nil, stories: [], searchText: nil, isHiddenStoriesSectionCollapsed: true) } func copy(isHiddenStoriesSectionCollapsed: Bool) -> Self { return .init( myStory: myStory, - stories: stories, + stories: unfilteredStories, + searchText: searchText, isHiddenStoriesSectionCollapsed: isHiddenStoriesSectionCollapsed ) } + var shouldDisplayMyStory: Bool { + return myStory != nil && searchText.isEmptyOrNil + } + var visibleStories: [StoryViewModel] { return stories.lazy.filter(\.isHidden.negated) } + // MARK: Hidden stories + var hiddenStories: [StoryViewModel] { return stories.lazy.filter(\.isHidden) } + + var unfilteredHiddenStories: [StoryViewModel] { + return unfilteredStories.lazy.filter(\.isHidden) + } + + var shouldDisplayHiddenStoriesHeader: Bool { + return !hiddenStories.isEmpty && searchText.isEmptyOrNil + } + + var shouldDisplayHiddenStories: Bool { + if shouldDisplayHiddenStoriesHeader { + return !isHiddenStoriesSectionCollapsed + } else { + return !hiddenStories.isEmpty + } + } } /// Encapsulates a set of changes to be applied when story list state changes. diff --git a/Signal/src/ViewControllers/HomeView/Stories/Transitions/StoryZoomAnimator.swift b/Signal/src/ViewControllers/HomeView/Stories/Transitions/StoryZoomAnimator.swift index 5d1f341910..fcfb16934b 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Transitions/StoryZoomAnimator.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Transitions/StoryZoomAnimator.swift @@ -48,12 +48,13 @@ class StoryZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning { return } - let centerCroppedDismissedFrame = containerView.convert( - context.thumbnailView.frame, - from: context.thumbnailView.superview - ) - - var storyViewDismissedSize = centerCroppedDismissedFrame.size + let getCenterCroppedDismissedFrame = { + return containerView.convert( + self.context.thumbnailView.frame, + from: self.context.thumbnailView.superview + ) + } + var storyViewDismissedSize = getCenterCroppedDismissedFrame().size // The thumbnailView uses contentMode scaleAspectFill to center crop the thumbnail, // but the storyView uses contentMode scaleAspectFit to letter box the full screen @@ -63,7 +64,7 @@ class StoryZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning { // and then crop it ourselves within storyViewContainer. if let storyThumbnailSize = context.storyThumbnailSize { if storyThumbnailSize.height > storyThumbnailSize.width { - if storyThumbnailSize.aspectRatio > centerCroppedDismissedFrame.size.aspectRatio { + if storyThumbnailSize.aspectRatio > storyViewDismissedSize.aspectRatio { storyViewDismissedSize.width = storyViewDismissedSize.height * storyThumbnailSize.aspectRatio } @@ -88,7 +89,7 @@ class StoryZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning { containerView.addSubview(storyViewContainer) containerView.addSubview(toVC.view) - storyViewContainer.frame = centerCroppedDismissedFrame + storyViewContainer.frame = getCenterCroppedDismissedFrame() context.storyView.frame = storyViewContainer.bounds.centerCropping(fullSize: storyViewDismissedSize) context.storyView.layoutIfNeeded() @@ -150,7 +151,7 @@ class StoryZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning { storyView.renderSize = TextAttachmentThumbnailView.defaultRenderSize } self.storyViewContainer.layer.cornerRadius = 12 - self.storyViewContainer.frame = centerCroppedDismissedFrame + self.storyViewContainer.frame = getCenterCroppedDismissedFrame() self.context.storyView.frame = self.storyViewContainer.bounds.centerCropping(fullSize: storyViewDismissedSize) self.context.storyView.layoutIfNeeded() } @@ -192,7 +193,7 @@ class StoryZoomAnimator: NSObject, UIViewControllerAnimatedTransitioning { self.animateChromeFade() self.animatePresentation( delay: self.presentationDelay, - endFrame: centerCroppedDismissedFrame, + endFrame: getCenterCroppedDismissedFrame(), storyViewEndSize: storyViewDismissedSize ) self.animateThumbnailFade(delay: self.presentationDuration + self.crossFadeDuration)