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
This commit is contained in:
parent
023a83bb60
commit
08dabd70d2
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user