From 09389e1e3444e3102b17b6dd70eefd0418fe6224 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 10:16:34 -0400 Subject: [PATCH 1/9] Move highlighter to a separate file --- .../xcshareddata/swiftpm/Package.resolved | 90 +++++++++++++++++++ .../Post/Search/PostSearchService.swift | 57 +----------- .../PostSearchViewModel+Highlighter.swift | 55 ++++++++++++ WordPress/WordPress.xcodeproj/project.pbxproj | 20 +++-- ...s.swift => PostSearchViewModelTests.swift} | 6 +- 5 files changed, 162 insertions(+), 66 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel+Highlighter.swift rename WordPress/WordPressTest/{PostSearchServiceTests.swift => PostSearchViewModelTests.swift} (86%) diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved index c564eb2e5be0..bd99391cc337 100644 --- a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,87 @@ { "object": { "pins": [ + { + "package": "AutomatticAbout", + "repositoryURL": "https://github.com/automattic/AutomatticAbout-swift", + "state": { + "branch": null, + "revision": "0f784591b324e5d3ddc5771808ef8eca923e3de2", + "version": "1.1.2" + } + }, + { + "package": "Charts", + "repositoryURL": "https://github.com/danielgindi/Charts", + "state": { + "branch": null, + "revision": "07b23476ad52b926be772f317d8f1d4511ee8d02", + "version": "4.1.0" + } + }, + { + "package": "CwlCatchException", + "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", + "state": { + "branch": null, + "revision": "35f9e770f54ce62dd8526470f14c6e137cef3eea", + "version": "2.1.1" + } + }, + { + "package": "CwlPreconditionTesting", + "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state": { + "branch": null, + "revision": "c21f7bab5ca8eee0a9998bbd17ca1d0eb45d4688", + "version": "2.1.0" + } + }, + { + "package": "Lottie", + "repositoryURL": "https://github.com/airbnb/lottie-ios.git", + "state": { + "branch": null, + "revision": "4ca8023b820b7d5d5ae1e2637c046e3dab0f45d0", + "version": "3.4.2" + } + }, + { + "package": "Nimble", + "repositoryURL": "https://github.com/Quick/Nimble", + "state": { + "branch": null, + "revision": "1f3bde57bde12f5e7b07909848c071e9b73d6edc", + "version": "10.0.0" + } + }, + { + "package": "ScreenObject", + "repositoryURL": "https://github.com/Automattic/ScreenObject", + "state": { + "branch": null, + "revision": "328db56c62aab91440ec5e07cc9f7eef6e26a26e", + "version": "0.2.3" + } + }, + { + "package": "swift-algorithms", + "repositoryURL": "https://github.com/apple/swift-algorithms", + "state": { + "branch": null, + "revision": "b14b7f4c528c942f121c8b860b9410b2bf57825e", + "version": "1.0.0" + } + }, + { + "package": "swift-numerics", + "repositoryURL": "https://github.com/apple/swift-numerics", + "state": { + "branch": null, + "revision": "0a5bc04095a675662cf24757cc0640aa2204253b", + "version": "1.0.2" + } + }, { "package": "BuildkiteTestCollector", "repositoryURL": "https://github.com/buildkite/test-collector-swift", @@ -9,6 +90,15 @@ "revision": "77c7f492f5c1c9ca159f73d18f56bbd1186390b0", "version": "0.3.0" } + }, + { + "package": "XCUITestHelpers", + "repositoryURL": "https://github.com/Automattic/XCUITestHelpers", + "state": { + "branch": null, + "revision": "5179cb69d58b90761cc713bdee7740c4889d3295", + "version": "0.4.0" + } } ] }, diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift index 666259ee54ec..1ae94a2063d0 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift @@ -96,7 +96,7 @@ final class PostSearchService { let terms = searchTerm .components(separatedBy: .whitespaces) .filter { !$0.isEmpty } - let titles = rawTitles.map { PostSearchService.makeTitle(for: $0 ?? "", terms: terms) } + let titles = rawTitles.map { PostSearchViewModel.higlight($0 ?? "", terms: terms) } let results = zip(posts, titles).map { PostSearchResult(post: $0, title: $1, searchTerm: searchTerm) } @@ -127,58 +127,3 @@ struct PostSearchCriteria: Hashable { let authorID: NSNumber? let tag: String? } - -extension PostSearchService { - // Both decoding & searching are expensive, so the service performs these - // operations in the background. - static func makeTitle(for title: String, terms: [String]) -> NSAttributedString { - let title = title - .trimmingCharacters(in: .whitespaces) - .stringByDecodingXMLCharacters() - - let ranges = terms.flatMap { - title.ranges(of: $0, options: [.caseInsensitive, .diacriticInsensitive]) - }.sorted { $0.lowerBound < $1.lowerBound } - - let string = NSMutableAttributedString(string: title, attributes: [ - .font: WPStyleGuide.fontForTextStyle(.body) - ]) - for range in collapseAdjacentRanges(ranges, in: title) { - string.setAttributes([ - .backgroundColor: UIColor.systemYellow.withAlphaComponent(0.25) - ], range: NSRange(range, in: title)) - } - return string - } - - private static func collapseAdjacentRanges(_ ranges: [Range], in string: String) -> [Range] { - var output: [Range] = [] - var ranges = ranges - while let rhs = ranges.popLast() { - if let lhs = ranges.last, - rhs.lowerBound > string.startIndex, - lhs.upperBound == string.index(before: rhs.lowerBound), - string[string.index(before: rhs.lowerBound)].isWhitespace { - let range = lhs.lowerBound.. [Range] { - var ranges: [Range] = [] - var startIndex = self.startIndex - while startIndex < endIndex, - let range = range(of: string, options: options, range: startIndex.. NSAttributedString { + let title = title + .trimmingCharacters(in: .whitespaces) + .stringByDecodingXMLCharacters() + + let ranges = terms.flatMap { + title.ranges(of: $0, options: [.caseInsensitive, .diacriticInsensitive]) + }.sorted { $0.lowerBound < $1.lowerBound } + + let string = NSMutableAttributedString(string: title, attributes: [ + .font: WPStyleGuide.fontForTextStyle(.body) + ]) + for range in collapseAdjacentRanges(ranges, in: title) { + string.setAttributes([ + .backgroundColor: UIColor.systemYellow.withAlphaComponent(0.25) + ], range: NSRange(range, in: title)) + } + return string + } + + private static func collapseAdjacentRanges(_ ranges: [Range], in string: String) -> [Range] { + var output: [Range] = [] + var ranges = ranges + while let rhs = ranges.popLast() { + if let lhs = ranges.last, + rhs.lowerBound > string.startIndex, + lhs.upperBound == string.index(before: rhs.lowerBound), + string[string.index(before: rhs.lowerBound)].isWhitespace { + ranges.removeLast() + ranges.append(lhs.lowerBound.. [Range] { + var ranges: [Range] = [] + var startIndex = self.startIndex + while startIndex < endIndex, + let range = range(of: string, options: options, range: startIndex.. Date: Thu, 19 Oct 2023 10:19:16 -0400 Subject: [PATCH 2/9] Make PostListItemViewModel a class --- .../Classes/ViewRelated/Post/PostListItemViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift index 37857a23cd5b..5aaa78b292ea 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift @@ -1,6 +1,6 @@ import Foundation -struct PostListItemViewModel { +final class PostListItemViewModel { let post: Post let title: String? let snippet: String? @@ -8,18 +8,19 @@ struct PostListItemViewModel { let date: String? let accessibilityIdentifier: String? - private var statusViewModel: PostCardStatusViewModel { .init(post: post) } - var status: String { statusViewModel.statusAndBadges(separatedBy: " · ")} var statusColor: UIColor { statusViewModel.statusColor } var author: String { statusViewModel.author } + private let statusViewModel: PostCardStatusViewModel + init(post: Post) { self.post = post self.title = post.titleForDisplay() self.snippet = post.contentPreviewForDisplay() self.imageURL = post.featuredImageURL self.date = post.displayDate()?.capitalizeFirstWord + self.statusViewModel = PostCardStatusViewModel(post: post) self.accessibilityIdentifier = post.slugForDisplay() } } From 9615bcb505857ff9ca3027f365401220c0552510 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 10:21:47 -0400 Subject: [PATCH 3/9] Remove higlighting from PostSearchService --- .../Post/Search/PostSearchService.swift | 40 +------------------ .../Search/PostSearchViewController.swift | 10 ++--- .../Post/Search/PostSearchViewModel.swift | 4 +- 3 files changed, 9 insertions(+), 45 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift index 1ae94a2063d0..1c8dbe3cf76f 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift @@ -2,7 +2,7 @@ import Foundation import CoreData protocol PostSearchServiceDelegate: AnyObject { - func service(_ service: PostSearchService, didAppendPosts page: [PostSearchResult]) + func service(_ service: PostSearchService, didAppendPosts page: [AbstractPost]) func serviceDidUpdateState(_ service: PostSearchService) } @@ -77,49 +77,13 @@ final class PostSearchService { let newPosts = posts.filter { !postIDs.contains($0.objectID) } postIDs.formUnion(newPosts.map(\.objectID)) - - preprocess(newPosts) { [weak self] in - guard let self else { return } - self.delegate?.service(self, didAppendPosts: $0) - } + self.delegate?.service(self, didAppendPosts: newPosts) case .failure(let error): self.error = error } isLoading = false delegate?.serviceDidUpdateState(self) } - - private func preprocess(_ posts: [AbstractPost], _ completion: @escaping ([PostSearchResult]) -> Void) { - let rawTitles = posts.map(\.postTitle) - let searchTerm = criteria.searchTerm - DispatchQueue.global().async { - let terms = searchTerm - .components(separatedBy: .whitespaces) - .filter { !$0.isEmpty } - let titles = rawTitles.map { PostSearchViewModel.higlight($0 ?? "", terms: terms) } - let results = zip(posts, titles).map { - PostSearchResult(post: $0, title: $1, searchTerm: searchTerm) - } - DispatchQueue.main.async { - completion(results) - } - } - } -} - -struct PostSearchResult { - let post: AbstractPost - /// Preprocessed titles with highlighted search ranges. - let title: NSAttributedString - let searchTerm: String - - var id: ID { ID(objectID: post.objectID, searchTerm: searchTerm) } - - struct ID: Hashable { - let objectID: NSManagedObjectID - /// Adding search term because the cell updates as the term changes. - let searchTerm: String - } } struct PostSearchCriteria: Hashable { diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift index 563bed26cb99..3b2777547478 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift @@ -11,7 +11,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS enum ItemID: Hashable { case token(AnyHashable) - case post(PostSearchResult.ID) + case post(NSManagedObjectID) } private let tableView = UITableView(frame: .zero, style: .plain) @@ -94,7 +94,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS snapshot.appendItems(tokenIDs, toSection: SectionID.tokens) snapshot.appendSections([SectionID.posts]) - let postIDs = viewModel.posts.map { ItemID.post($0.id) } + let postIDs = viewModel.posts.map { ItemID.post($0.objectID) } snapshot.appendItems(postIDs, toSection: SectionID.posts) dataSource.apply(snapshot, animatingDifferences: false) @@ -114,10 +114,10 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS case .posts: // TODO: Update the cell design let cell = tableView.dequeueReusableCell(withIdentifier: Constants.postCellID, for: indexPath) - let result = viewModel.posts[indexPath.row] + let post = viewModel.posts[indexPath.row] var configuration = cell.defaultContentConfiguration() - configuration.attributedText = result.title - configuration.secondaryText = result.post.latest().dateStringForDisplay() + configuration.text = post.titleForDisplay() + configuration.secondaryText = post.latest().dateStringForDisplay() configuration.secondaryTextProperties.color = .secondaryLabel cell.contentConfiguration = configuration return cell diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift index bbbf789da17f..e3a8990b4c67 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift @@ -10,7 +10,7 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { didSet { didUpdateData?() } } - private(set) var posts: [PostSearchResult] = [] { + private(set) var posts: [AbstractPost] = [] { didSet { didUpdateData?() } } @@ -122,7 +122,7 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { // MARK: - PostSearchServiceDelegate - func service(_ service: PostSearchService, didAppendPosts posts: [PostSearchResult]) { + func service(_ service: PostSearchService, didAppendPosts posts: [AbstractPost]) { assert(Thread.isMainThread) if isRefreshing { From 11445d844788ffa63d67b82eee0f1dca5d3c779a Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 10:26:36 -0400 Subject: [PATCH 4/9] Use PostItemCell in search --- .../Search/PostSearchViewController.swift | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift index 3b2777547478..8dee9214efe1 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift @@ -70,7 +70,8 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS view.pinSubviewToAllEdges(tableView) tableView.register(PostSearchTokenTableCell.self, forCellReuseIdentifier: Constants.tokenCellID) - tableView.register(UITableViewCell.self, forCellReuseIdentifier: Constants.postCellID) + tableView.register(PostListCell.self, forCellReuseIdentifier: Constants.postCellID) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: Constants.pageCellID) tableView.dataSource = dataSource tableView.delegate = self @@ -112,15 +113,23 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS cell.separatorInset = UIEdgeInsets(top: 0, left: view.bounds.size.width, bottom: 0, right: 0) // Hide the native separator return cell case .posts: - // TODO: Update the cell design - let cell = tableView.dequeueReusableCell(withIdentifier: Constants.postCellID, for: indexPath) - let post = viewModel.posts[indexPath.row] - var configuration = cell.defaultContentConfiguration() - configuration.text = post.titleForDisplay() - configuration.secondaryText = post.latest().dateStringForDisplay() - configuration.secondaryTextProperties.color = .secondaryLabel - cell.contentConfiguration = configuration - return cell + let item = viewModel.posts[indexPath.row] + if let post = item as? Post { + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.postCellID, for: indexPath) as! PostListCell + cell.configure(with: .init(post: post)) + return cell + } else if let page = item as? Page { + // TODO: Update the cell design + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.pageCellID, for: indexPath) + var configuration = cell.defaultContentConfiguration() + configuration.text = page.titleForDisplay() + configuration.secondaryText = page.latest().dateStringForDisplay() + configuration.secondaryTextProperties.color = .secondaryLabel + cell.contentConfiguration = configuration + return cell + } else { + fatalError("Unsupported item: \(type(of: item))") + } } } @@ -182,5 +191,6 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS private enum Constants { static let postCellID = "postCellID" + static let pageCellID = "pageCellID" static let tokenCellID = "suggestedTokenCellID" } From 4f70d9d1d7d87ced33a75906cf688d049847a07b Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 10:43:40 -0400 Subject: [PATCH 5/9] Create search item ViewModels in PostSearchViewModel and unique them --- .../Search/PostSearchViewController.swift | 14 +++-- .../Post/Search/PostSearchViewModel.swift | 54 ++++++++++++++++--- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift index 8dee9214efe1..3ecd8bcf8e52 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift @@ -11,7 +11,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS enum ItemID: Hashable { case token(AnyHashable) - case post(NSManagedObjectID) + case result(NSManagedObjectID) } private let tableView = UITableView(frame: .zero, style: .plain) @@ -95,7 +95,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS snapshot.appendItems(tokenIDs, toSection: SectionID.tokens) snapshot.appendSections([SectionID.posts]) - let postIDs = viewModel.posts.map { ItemID.post($0.objectID) } + let postIDs = viewModel.results.map { ItemID.result($0.objectID) } snapshot.appendItems(postIDs, toSection: SectionID.posts) dataSource.apply(snapshot, animatingDifferences: false) @@ -113,12 +113,12 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS cell.separatorInset = UIEdgeInsets(top: 0, left: view.bounds.size.width, bottom: 0, right: 0) // Hide the native separator return cell case .posts: - let item = viewModel.posts[indexPath.row] - if let post = item as? Post { + switch viewModel.results[indexPath.row] { + case .post(let post): let cell = tableView.dequeueReusableCell(withIdentifier: Constants.postCellID, for: indexPath) as! PostListCell - cell.configure(with: .init(post: post)) + cell.configure(with: post) return cell - } else if let page = item as? Page { + case .page(let page): // TODO: Update the cell design let cell = tableView.dequeueReusableCell(withIdentifier: Constants.pageCellID, for: indexPath) var configuration = cell.defaultContentConfiguration() @@ -127,8 +127,6 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS configuration.secondaryTextProperties.color = .secondaryLabel cell.contentConfiguration = configuration return cell - } else { - fatalError("Unsupported item: \(type(of: item))") } } } diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift index e3a8990b4c67..ec4804d9da0d 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift @@ -10,7 +10,7 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { didSet { didUpdateData?() } } - private(set) var posts: [AbstractPost] = [] { + private(set) var results: [PostSearchResultItem] = [] { didSet { didUpdateData?() } } @@ -21,6 +21,7 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { private let coreData: CoreDataStack private let entityName: String + private var postViewModels: [NSManagedObjectID: PostListItemViewModel] = [:] private var searchService: PostSearchService? private var localSearchTask: Task? private let suggestionsService: PostSearchSuggestionsService @@ -75,7 +76,7 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { let token = suggestedTokens[index] cancelCurrentRemoteSearch() suggestedTokens = [] - posts = [] + results = [] selectedTokens.append(token) searchTerm = "" } @@ -86,8 +87,8 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { cancelCurrentRemoteSearch() guard searchTerm.count > 1 || !selectedTokens.isEmpty else { - if !posts.isEmpty { - posts = [] + if !results.isEmpty { + results = [] } return } @@ -125,11 +126,12 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { func service(_ service: PostSearchService, didAppendPosts posts: [AbstractPost]) { assert(Thread.isMainThread) + let items = posts.map(getSearchResultItem) if isRefreshing { - self.posts = posts + self.results = items isRefreshing = false } else { - self.posts += posts + self.results += items } } @@ -137,9 +139,9 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { assert(Thread.isMainThread) if isRefreshing && service.error != nil { - posts = [] + results = [] } - if service.isLoading && (!isRefreshing || posts.isEmpty) { + if service.isLoading && (!isRefreshing || results.isEmpty) { footerState = .loading } else if service.error != nil { footerState = .error @@ -148,6 +150,28 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { } } + // MARK: - Results + + private func getSearchResultItem(for item: AbstractPost) -> PostSearchResultItem { + switch item { + case let post as Post: + return .post(getViewModel(for: post)) + case let page as Page: + return .page(page) + default: + fatalError("Unsupported item: \(type(of: item))") + } + } + + private func getViewModel(for post: Post) -> PostListItemViewModel { + if let viewModel = postViewModels[post.objectID] { + return viewModel + } + let viewModel = PostListItemViewModel(post: post) + postViewModels[post.objectID] = viewModel + return viewModel + } + // MARK: - Search Tokens private func updateSuggestedTokens(for searchTerm: String) { @@ -160,3 +184,17 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { } } } + +enum PostSearchResultItem { + case post(PostListItemViewModel) + case page(Page) + + var objectID: NSManagedObjectID { + switch self { + case .post(let viewModel): + return viewModel.post.objectID + case .page(let page): + return page.objectID + } + } +} From 7b8f23f2cf457bfe43a140d18e0920ef7aa23602 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 10:53:13 -0400 Subject: [PATCH 6/9] Move title creation to PostListItemViewModel --- .../ViewRelated/Post/PostListCell.swift | 36 ++++++------------- .../Post/PostListItemViewModel.swift | 34 +++++++++++++++--- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/PostListCell.swift b/WordPress/Classes/ViewRelated/Post/PostListCell.swift index a6abb6f545ac..8769806b1b91 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListCell.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListCell.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import Combine final class PostListCell: UITableViewCell, Reusable { @@ -21,6 +22,7 @@ final class PostListCell: UITableViewCell, Reusable { private let titleAndSnippetLabel = UILabel() private let featuredImageView = CachedAnimatedImageView() private let statusLabel = UILabel() + private var cancellables: [AnyCancellable] = [] // MARK: - Properties @@ -39,10 +41,18 @@ final class PostListCell: UITableViewCell, Reusable { // MARK: - Public + override func prepareForReuse() { + super.prepareForReuse() + + cancellables = [] + } + func configure(with viewModel: PostListItemViewModel) { headerView.configure(with: viewModel) - configureTitleAndSnippet(with: viewModel) + viewModel.$title.sink { [titleAndSnippetLabel] in + titleAndSnippetLabel.attributedText = $0 + }.store(in: &cancellables) imageLoader.prepareForReuse() featuredImageView.isHidden = viewModel.imageURL == nil @@ -58,30 +68,6 @@ final class PostListCell: UITableViewCell, Reusable { statusLabel.isHidden = viewModel.status.isEmpty } - private func configureTitleAndSnippet(with viewModel: PostListItemViewModel) { - var titleAndSnippetString = NSMutableAttributedString() - - if let title = viewModel.title, !title.isEmpty { - let attributes: [NSAttributedString.Key: Any] = [ - .font: WPStyleGuide.fontForTextStyle(.callout, fontWeight: .semibold), - .foregroundColor: UIColor.text - ] - let titleAttributedString = NSAttributedString(string: "\(title)\n", attributes: attributes) - titleAndSnippetString.append(titleAttributedString) - } - - if let snippet = viewModel.snippet, !snippet.isEmpty { - let attributes: [NSAttributedString.Key: Any] = [ - .font: WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular), - .foregroundColor: UIColor.textSubtle - ] - let snippetAttributedString = NSAttributedString(string: snippet, attributes: attributes) - titleAndSnippetString.append(snippetAttributedString) - } - - titleAndSnippetLabel.attributedText = titleAndSnippetString - } - // MARK: - Setup private func setupViews() { diff --git a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift index 5aaa78b292ea..7ad36f97d68a 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift @@ -2,8 +2,7 @@ import Foundation final class PostListItemViewModel { let post: Post - let title: String? - let snippet: String? + @Published var title: NSAttributedString let imageURL: URL? let date: String? let accessibilityIdentifier: String? @@ -16,8 +15,7 @@ final class PostListItemViewModel { init(post: Post) { self.post = post - self.title = post.titleForDisplay() - self.snippet = post.contentPreviewForDisplay() + self.title = makeContentAttributedString(for: post) self.imageURL = post.featuredImageURL self.date = post.displayDate()?.capitalizeFirstWord self.statusViewModel = PostCardStatusViewModel(post: post) @@ -25,6 +23,34 @@ final class PostListItemViewModel { } } +private func makeContentAttributedString(for post: Post) -> NSAttributedString { + let title = post.titleForDisplay() + let snippet = post.contentPreviewForDisplay() + + let string = NSMutableAttributedString() + if !title.isEmpty { + let attributes: [NSAttributedString.Key: Any] = [ + .font: WPStyleGuide.fontForTextStyle(.callout, fontWeight: .semibold), + .foregroundColor: UIColor.text + ] + let titleAttributedString = NSAttributedString(string: title, attributes: attributes) + string.append(titleAttributedString) + } + if !snippet.isEmpty { + if string.length > 0 { + string.append(NSAttributedString(string: "\n")) + } + let attributes: [NSAttributedString.Key: Any] = [ + .font: WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular), + .foregroundColor: UIColor.textSubtle + ] + let snippetAttributedString = NSAttributedString(string: snippet, attributes: attributes) + string.append(snippetAttributedString) + } + + return string +} + private extension String { var capitalizeFirstWord: String { let firstLetter = self.prefix(1).capitalized From 9ef7d214e1d240062fef97a18e30c77ccfa0d788 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 11:21:57 -0400 Subject: [PATCH 7/9] Implement highlghting --- .../Post/Search/PostSearchService.swift | 2 +- .../PostSearchViewModel+Highlighter.swift | 16 ++++++++++ .../Post/Search/PostSearchViewModel.swift | 29 ++++++++++++++++++- .../PostSearchViewModelTests.swift | 8 ++--- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift index 1c8dbe3cf76f..037f5dab8dd6 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchService.swift @@ -13,9 +13,9 @@ final class PostSearchService { weak var delegate: PostSearchServiceDelegate? + let criteria: PostSearchCriteria private let blog: Blog private let settings: PostListFilterSettings - private let criteria: PostSearchCriteria private let coreDataStack: CoreDataStack private var postIDs: Set = [] diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel+Highlighter.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel+Highlighter.swift index 2202a71e78ac..36f175166a06 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel+Highlighter.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel+Highlighter.swift @@ -1,6 +1,22 @@ import UIKit extension PostSearchViewModel { + static func highlight(terms: [String], in attributedString: NSMutableAttributedString) { + attributedString.removeAttribute(.backgroundColor, range: NSRange(location: 0, length: attributedString.length)) + + let string = attributedString.string + + let ranges = terms.flatMap { + string.ranges(of: $0, options: [.caseInsensitive, .diacriticInsensitive]) + }.sorted { $0.lowerBound < $1.lowerBound } + + for range in collapseAdjacentRanges(ranges, in: string) { + attributedString.addAttributes([ + .backgroundColor: UIColor.systemYellow.withAlphaComponent(0.25) + ], range: NSRange(range, in: string)) + } + } + // Both decoding & searching are expensive, so the service performs these // operations in the background. static func higlight(_ title: String, terms: [String]) -> NSAttributedString { diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift index ec4804d9da0d..0cd3718a8ad4 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift @@ -49,7 +49,7 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { $searchTerm .dropFirst() .removeDuplicates() - .sink { [weak self] in self?.updateSuggestedTokens(for: $0) } + .sink { [weak self] in self?.didUpdateSearchTerm($0) } .store(in: &cancellables) $searchTerm.map { $0.trimmingCharacters(in: .whitespaces) } @@ -63,6 +63,11 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { // MARK: - Events + private func didUpdateSearchTerm(_ searchTerm: String) { + updateHighlightForSearchResults(for: searchTerm) + updateSuggestedTokens(for: searchTerm) + } + func didReachBottom() { guard let searchService, searchService.error == nil else { return } searchService.loadMore() @@ -133,6 +138,9 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { } else { self.results += items } + + // Updating for current searchTerm, not the one that the service searched for + updateHighlightForSearchResults(for: searchTerm) } func serviceDidUpdateState(_ service: PostSearchService) { @@ -172,6 +180,25 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { return viewModel } + // MARK: - Highlighter + + private func updateHighlightForSearchResults(for searchTerm: String) { + let terms = searchTerm + .trimmingCharacters(in: .whitespaces) + .components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + for item in results { + switch item { + case .post(let viewModel): + let string = NSMutableAttributedString(attributedString: viewModel.title) + PostSearchViewModel.highlight(terms: terms, in: string) + viewModel.title = string + case .page: + break // TODO: Implement highlighting + } + } + } + // MARK: - Search Tokens private func updateSuggestedTokens(for searchTerm: String) { diff --git a/WordPress/WordPressTest/PostSearchViewModelTests.swift b/WordPress/WordPressTest/PostSearchViewModelTests.swift index e56787f0dbbe..97cf8001e0cc 100644 --- a/WordPress/WordPressTest/PostSearchViewModelTests.swift +++ b/WordPress/WordPressTest/PostSearchViewModelTests.swift @@ -5,10 +5,10 @@ import XCTest class PostSearchViewModelTests: XCTestCase { func testThatAdjacentRangesAreCollapsed() throws { // GIVEN - let title = "one two xxxxx one" + let string = NSMutableAttributedString(string: "one two xxxxx one") // WHEN - let string = PostSearchViewModel.higlight(title, terms: ["one", "two"]) + PostSearchViewModel.highlight(terms: ["one", "two"], in: string) // THEN XCTAssertTrue(string.hasAttribute(.backgroundColor, in: NSRange(location: 0, length: 7))) @@ -18,10 +18,10 @@ class PostSearchViewModelTests: XCTestCase { func testThatCaseIsIgnored() { // GIVEN - let title = "One xxxxx óne" + let string = NSMutableAttributedString(string: "One xxxxx óne") // WHEN - let string = PostSearchViewModel.higlight(title, terms: ["one"]) + PostSearchViewModel.highlight(terms: ["one"], in: string) // THEN XCTAssertTrue(string.hasAttribute(.backgroundColor, in: NSRange(location: 0, length: 3))) From 3420a709766f17cdba4b67f285b4dcbfa1bccf53 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Oct 2023 11:32:20 -0400 Subject: [PATCH 8/9] Remote top padding from sections --- .../ViewRelated/Post/Search/PostSearchViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift index 3ecd8bcf8e52..3c1863c9da2c 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewController.swift @@ -75,6 +75,7 @@ final class PostSearchViewController: UIViewController, UITableViewDelegate, UIS tableView.dataSource = dataSource tableView.delegate = self + tableView.sectionHeaderTopPadding = 0 } override func viewDidLayoutSubviews() { From 2756f7c4f9ff59d433c0deb48978b3b1288788fb Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Oct 2023 08:04:06 -0400 Subject: [PATCH 9/9] Rename content label --- .../ViewRelated/Post/PostListCell.swift | 18 +++++++++--------- .../Post/PostListItemViewModel.swift | 4 ++-- .../Post/Search/PostSearchViewModel.swift | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/PostListCell.swift b/WordPress/Classes/ViewRelated/Post/PostListCell.swift index 8769806b1b91..03ac2d5e9011 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListCell.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListCell.swift @@ -19,7 +19,7 @@ final class PostListCell: UITableViewCell, Reusable { }() private let headerView = PostListHeaderView() - private let titleAndSnippetLabel = UILabel() + private let contentLabel = UILabel() private let featuredImageView = CachedAnimatedImageView() private let statusLabel = UILabel() private var cancellables: [AnyCancellable] = [] @@ -50,8 +50,8 @@ final class PostListCell: UITableViewCell, Reusable { func configure(with viewModel: PostListItemViewModel) { headerView.configure(with: viewModel) - viewModel.$title.sink { [titleAndSnippetLabel] in - titleAndSnippetLabel.attributedText = $0 + viewModel.$content.sink { [contentLabel] in + contentLabel.attributedText = $0 }.store(in: &cancellables) imageLoader.prepareForReuse() @@ -71,13 +71,13 @@ final class PostListCell: UITableViewCell, Reusable { // MARK: - Setup private func setupViews() { - setupTitleAndSnippetLabel() + setupcontentLabel() setupFeaturedImageView() setupStatusLabel() contentStackView.translatesAutoresizingMaskIntoConstraints = false contentStackView.addArrangedSubviews([ - titleAndSnippetLabel, + contentLabel, featuredImageView ]) contentStackView.spacing = 16 @@ -98,10 +98,10 @@ final class PostListCell: UITableViewCell, Reusable { contentView.backgroundColor = .systemBackground } - private func setupTitleAndSnippetLabel() { - titleAndSnippetLabel.translatesAutoresizingMaskIntoConstraints = false - titleAndSnippetLabel.adjustsFontForContentSizeCategory = true - titleAndSnippetLabel.numberOfLines = 3 + private func setupcontentLabel() { + contentLabel.translatesAutoresizingMaskIntoConstraints = false + contentLabel.adjustsFontForContentSizeCategory = true + contentLabel.numberOfLines = 3 } private func setupFeaturedImageView() { diff --git a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift index 7ad36f97d68a..ac1b70bca99e 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListItemViewModel.swift @@ -2,7 +2,7 @@ import Foundation final class PostListItemViewModel { let post: Post - @Published var title: NSAttributedString + @Published var content: NSAttributedString let imageURL: URL? let date: String? let accessibilityIdentifier: String? @@ -15,7 +15,7 @@ final class PostListItemViewModel { init(post: Post) { self.post = post - self.title = makeContentAttributedString(for: post) + self.content = makeContentAttributedString(for: post) self.imageURL = post.featuredImageURL self.date = post.displayDate()?.capitalizeFirstWord self.statusViewModel = PostCardStatusViewModel(post: post) diff --git a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift index 0cd3718a8ad4..d91ec7e6afc6 100644 --- a/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Search/PostSearchViewModel.swift @@ -190,9 +190,9 @@ final class PostSearchViewModel: NSObject, PostSearchServiceDelegate { for item in results { switch item { case .post(let viewModel): - let string = NSMutableAttributedString(attributedString: viewModel.title) + let string = NSMutableAttributedString(attributedString: viewModel.content) PostSearchViewModel.highlight(terms: terms, in: string) - viewModel.title = string + viewModel.content = string case .page: break // TODO: Implement highlighting }