Skip to content

Commit 1d85989

Browse files
authored
Reader: Add new menu (#23667)
* Add .readerReset FF * Add initial empty new reader VC * Add initial reader sidebar integration * Update sidebar design and remove the code popping the screens * Rename ReaderPresenter * Add Lazy and extract Reader navigation to ReaderPresenter (new) * Integrate ReaderPresenter on iPhone * Update sidebar style on iPhone * Update back button style * Retain state of selecting the same VC * Use Lazy for NNotificationsSplitViewContent * Simplify displayingContent * Cleaned up SplitViewRootPresenter * Cleanup * Add large title for Reader * Disable .readerReset FF in ui tests * Update Reader sidebar style * Remove sidebar icon * Fix subscriptions screen navigation title style * Fix an issue with subscriptions screen crashing when a site is selected * Fix Notification Settings screen * Fix add subscriptions screen * Remove JetpackBanner to simplify view hierarchy * Fix bottom inset in ReaderStreamVC * Remove haptics when unfollowing * Update to use plain table style * Fix an issue with search breaking sidebar * Disable section that are not being edited * Remove ReaderAnnouncementHeaderView * Remove redundant notes * Add showInitialSelection * Add LazyTests * Update color-studio dependency * Update /color-studio to point to trunk
1 parent ebfd142 commit 1d85989

25 files changed

+520
-588
lines changed

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ let package = Package(
4848
// We can't use wordpress-rs branches nor commits here. Only tags work.
4949
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-swift-20240813"),
5050
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", revision: "c31879b834ef3ca5ea6b09040c96093ef627e029"),
51-
.package(url: "https://github.com/Automattic/color-studio", branch: "add/swift-file-output"),
51+
.package(url: "https://github.com/Automattic/color-studio", branch: "trunk"),
5252
],
5353
targets: XcodeSupport.targets + [
5454
.target(name: "JetpackStatsWidgetsCore"),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
/// A lazy property that can be reset and allows you accessing the stored value
4+
/// without initializing it if needed.
5+
@propertyWrapper
6+
public final class Lazy<Value> {
7+
private let closure: () -> Value
8+
public var value: Value?
9+
10+
public init(wrappedValue: @autoclosure @escaping () -> Value) {
11+
self.closure = wrappedValue
12+
}
13+
14+
public var wrappedValue: Value {
15+
if let value {
16+
return value
17+
}
18+
let value = closure()
19+
self.value = value
20+
return value
21+
}
22+
23+
public var projectedValue: Lazy<Value> { self }
24+
25+
public func reset() {
26+
self.value = nil
27+
}
28+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import XCTest
2+
@testable import WordPressShared
3+
4+
class LazyTests: XCTestCase {
5+
@Lazy
6+
var container = Container()
7+
8+
final class Container {
9+
static var initCallCount = 0
10+
11+
var name = "hello"
12+
13+
init() {
14+
Container.initCallCount += 1
15+
}
16+
}
17+
18+
override func tearDown() {
19+
super.tearDown()
20+
21+
Container.initCallCount = 0
22+
}
23+
24+
func testLazyProperty() {
25+
XCTAssertEqual(Container.initCallCount, 0, "Has to be created lazily")
26+
27+
// Accessing value without triggering init
28+
XCTAssertNil($container.value)
29+
XCTAssertEqual(Container.initCallCount, 0, "Accessing the projected value should not trigger init")
30+
31+
// Accessing value while initializing it lazily
32+
XCTAssertEqual(container.name, "hello")
33+
XCTAssertNotNil($container.value)
34+
XCTAssertEqual(Container.initCallCount, 1)
35+
36+
// Using the cached value
37+
container.name = "here goes nothing"
38+
XCTAssertEqual(Container.initCallCount, 1, "Lazily created value is retained")
39+
40+
// Resetting the value
41+
$container.reset()
42+
XCTAssertNil($container.value)
43+
}
44+
}

WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import Foundation
2+
import UIKit
3+
import SwiftUI
4+
import Combine
5+
import WordPressUI
6+
7+
/// Manages top-level Reader navigation.
8+
final class ReaderPresenter: NSObject, SplitViewDisplayable {
9+
private let sidebarViewModel = ReaderSidebarViewModel()
10+
11+
// The view controllers used during split view presentation.
12+
let sidebar: ReaderSidebarViewController
13+
let supplementary: UINavigationController
14+
var secondary: UINavigationController
15+
16+
/// The navigation controller for the main content when shown using tabs.
17+
private let mainNavigationController = UINavigationController()
18+
private var latestContentVC: UIViewController?
19+
20+
private var viewContext: NSManagedObjectContext {
21+
ContextManager.shared.mainContext
22+
}
23+
24+
private var selectionObserver: AnyCancellable?
25+
26+
override init() {
27+
secondary = UINavigationController()
28+
sidebar = ReaderSidebarViewController(viewModel: sidebarViewModel)
29+
sidebar.navigationItem.largeTitleDisplayMode = .automatic
30+
supplementary = UINavigationController(rootViewController: sidebar)
31+
supplementary.navigationBar.prefersLargeTitles = true
32+
33+
super.init()
34+
35+
sidebarViewModel.navigate = { [weak self] in self?.navigate(to: $0) }
36+
}
37+
38+
// TODO: (reader) update to allow seamless transitions between split view and tabs
39+
@objc func prepareForTabBarPresentation() -> UINavigationController {
40+
sidebarViewModel.isCompact = true
41+
mainNavigationController.navigationBar.prefersLargeTitles = true
42+
mainNavigationController.viewControllers = [sidebar]
43+
sidebar.navigationItem.backButtonDisplayMode = .minimal
44+
showInitialSelection()
45+
return mainNavigationController
46+
}
47+
48+
// MARK: - Navigation
49+
50+
func showInitialSelection() {
51+
// -warning: List occasionally sets the selection to `nil` when switching items.
52+
selectionObserver = sidebarViewModel.$selection.compactMap { $0 }
53+
.removeDuplicates { [weak self] in
54+
guard $0 == $1 else { return false }
55+
self?.popMainNavigationController()
56+
return true
57+
}
58+
.sink { [weak self] in self?.configure(for: $0) }
59+
}
60+
61+
private func configure(for selection: ReaderSidebarItem) {
62+
switch selection {
63+
case .main(let screen):
64+
show(makeViewController(for: screen))
65+
case .allSubscriptions:
66+
show(makeAllSubscriptionsViewController(), isLargeTitle: true)
67+
case .subscription(let objectID):
68+
show(makeViewController(withTopicID: objectID))
69+
case .list(let objectID):
70+
show(makeViewController(withTopicID: objectID))
71+
case .tag(let objectID):
72+
show(makeViewController(withTopicID: objectID))
73+
case .organization(let objectID):
74+
show(makeViewController(withTopicID: objectID))
75+
}
76+
}
77+
78+
private func popMainNavigationController() {
79+
if let splitViewController {
80+
let secondaryVC = splitViewController.viewController(for: .secondary)
81+
(secondaryVC as? UINavigationController)?.popToRootViewController(animated: true)
82+
hideSupplementaryColumnIfNeeded()
83+
} else {
84+
if let latestContentVC {
85+
// Return to the previous view controller preserving its state
86+
mainNavigationController.pushViewController(latestContentVC, animated: true)
87+
}
88+
}
89+
}
90+
91+
private func hideSupplementaryColumnIfNeeded() {
92+
if sidebar.didAppear, let splitVC = sidebar.splitViewController, splitVC.splitBehavior == .overlay {
93+
DispatchQueue.main.async {
94+
splitVC.hide(.supplementary)
95+
}
96+
}
97+
}
98+
99+
private func makeViewController<T: ReaderAbstractTopic>(withTopicID objectID: TaggedManagedObjectID<T>) -> UIViewController {
100+
do {
101+
let topic = try viewContext.existingObject(with: objectID)
102+
return ReaderStreamViewController.controllerWithTopic(topic)
103+
} catch {
104+
wpAssertionFailure("tag missing", userInfo: ["error": "\(error)"])
105+
return makeErrorViewController()
106+
}
107+
}
108+
109+
private func makeViewController(for screen: ReaderStaticScreen) -> UIViewController {
110+
switch screen {
111+
case .recent, .discover, .likes:
112+
if let topic = screen.topicType.flatMap(sidebarViewModel.getTopic) {
113+
if screen == .discover {
114+
return ReaderCardsStreamViewController.controller(topic: topic)
115+
} else {
116+
return ReaderStreamViewController.controllerWithTopic(topic)
117+
}
118+
} else {
119+
return makeErrorViewController() // This should never happen
120+
}
121+
case .saved:
122+
return ReaderStreamViewController.controllerForContentType(.saved)
123+
case .search:
124+
return ReaderSearchViewController.controller(withSearchText: "")
125+
}
126+
}
127+
128+
private func makeAllSubscriptionsViewController() -> UIViewController {
129+
let view = ReaderSubscriptionsView() { [weak self] selection in
130+
let streamVC = ReaderStreamViewController.controllerWithTopic(selection)
131+
self?.push(streamVC)
132+
}.environment(\.managedObjectContext, viewContext)
133+
let hostVC = UIHostingController(rootView: view)
134+
hostVC.title = ReaderSubscriptionsView.navigationTitle
135+
if sidebarViewModel.isCompact {
136+
hostVC.navigationItem.largeTitleDisplayMode = .never
137+
}
138+
return hostVC
139+
}
140+
141+
private func navigate(to item: ReaderSidebarNavigation) {
142+
switch item {
143+
case .addTag:
144+
let addTagVC = UIHostingController(rootView: ReaderTagsAddTagView())
145+
addTagVC.modalPresentationStyle = .formSheet
146+
addTagVC.preferredContentSize = CGSize(width: 420, height: 124)
147+
sidebar.present(addTagVC, animated: true, completion: nil)
148+
case .discoverTags:
149+
let tags = viewContext.allObjects(
150+
ofType: ReaderTagTopic.self,
151+
matching: ReaderSidebarTagsSection.predicate,
152+
sortedBy: [NSSortDescriptor(SortDescriptor<ReaderTagTopic>(\.title, order: .forward))]
153+
)
154+
let interestsVC = ReaderSelectInterestsViewController(topics: tags)
155+
interestsVC.didSaveInterests = { [weak self] _ in
156+
self?.sidebar.dismiss(animated: true)
157+
}
158+
let navigationVC = UINavigationController(rootViewController: interestsVC)
159+
navigationVC.modalPresentationStyle = .formSheet
160+
sidebar.present(navigationVC, animated: true, completion: nil)
161+
}
162+
}
163+
164+
private func makeErrorViewController() -> UIViewController {
165+
UIHostingController(rootView: EmptyStateView(SharedStrings.Error.generic, systemImage: "exclamationmark.circle"))
166+
}
167+
168+
/// Shows the given view controller by either displaying it in the `.secondary`
169+
/// column (split view) or pushing to the navigation stack.
170+
private func show(_ viewController: UIViewController, isLargeTitle: Bool = false) {
171+
if let splitViewController {
172+
let navigationVC = UINavigationController(rootViewController: viewController)
173+
if isLargeTitle {
174+
navigationVC.navigationBar.prefersLargeTitles = true
175+
}
176+
splitViewController.setViewController(navigationVC, for: .secondary)
177+
} else {
178+
latestContentVC = viewController
179+
mainNavigationController.pushViewController(viewController, animated: true)
180+
}
181+
}
182+
183+
/// Pushes the view controller to either the existing navigation stack in
184+
/// the `.secondary` column (split view) or to the main navigation stack.
185+
private func push(_ viewController: UIViewController) {
186+
if let splitViewController {
187+
let navigationVC = splitViewController.viewController(for: .secondary) as? UINavigationController
188+
wpAssert(navigationVC != nil)
189+
navigationVC?.pushViewController(viewController, animated: true)
190+
} else {
191+
mainNavigationController.pushViewController(viewController, animated: true)
192+
}
193+
}
194+
195+
private var splitViewController: UISplitViewController? {
196+
sidebar.splitViewController
197+
}
198+
199+
// MARK: - Deep Links (ReaderNavigationPath)
200+
201+
func navigate(to path: ReaderNavigationPath) {
202+
let viewModel = sidebarViewModel
203+
204+
switch path {
205+
case .recent:
206+
viewModel.selection = .main(.recent)
207+
case .discover:
208+
viewModel.selection = .main(.discover)
209+
case .likes:
210+
viewModel.selection = .main(.likes)
211+
case .search:
212+
viewModel.selection = .main(.search)
213+
case .subscriptions:
214+
viewModel.selection = .allSubscriptions
215+
case let .post(postID, siteID, isFeed):
216+
viewModel.selection = nil
217+
show(ReaderDetailViewController.controllerWithPostID(NSNumber(value: postID), siteID: NSNumber(value: siteID), isFeed: isFeed))
218+
case let .postURL(url):
219+
viewModel.selection = nil
220+
show(ReaderDetailViewController.controllerWithPostURL(url))
221+
case let .topic(topic):
222+
viewModel.selection = nil
223+
show(ReaderStreamViewController.controllerWithTopic(topic))
224+
case let .tag(slug):
225+
viewModel.selection = nil
226+
show(ReaderStreamViewController.controllerWithTagSlug(slug))
227+
}
228+
}
229+
230+
// MARK: - SplitViewDisplayable
231+
232+
func displayed(in splitVC: UISplitViewController) {
233+
if secondary.viewControllers.isEmpty {
234+
showInitialSelection()
235+
}
236+
}
237+
}

WordPress/Classes/System/SplitViewRootPresenter+Reader.swift

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)